fargate_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. package aws
  2. import (
  3. "encoding/json"
  4. "net/http"
  5. "net/http/httptest"
  6. "os"
  7. "testing"
  8. "time"
  9. "github.com/opencost/opencost/core/pkg/clustercache"
  10. )
  11. var testRegionPricing = FargateRegionPricing{
  12. usageTypeFargateLinuxX86CPU: 0.0404800000,
  13. usageTypeFargateLinuxX86RAM: 0.0044450000,
  14. usageTypeFargateLinuxArmCPU: 0.0323800000,
  15. usageTypeFargateLinuxArmRAM: 0.0035600000,
  16. usageTypeFargateWindowsCPU: 0.0465520000,
  17. usageTypeFargateWindowsLicense: 0.0460000000,
  18. usageTypeFargateWindowsRAM: 0.0051117500,
  19. }
  20. func TestFargatePricing_populatePricing(t *testing.T) {
  21. // Load test data
  22. testDataPath := "testdata/ecs-pricing-us-east-1.json"
  23. data, err := os.ReadFile(testDataPath)
  24. if err != nil {
  25. t.Fatalf("Failed to read test data: %v", err)
  26. }
  27. var pricing AWSPricing
  28. err = json.Unmarshal(data, &pricing)
  29. if err != nil {
  30. t.Fatalf("Failed to unmarshal test data: %v", err)
  31. }
  32. tests := []struct {
  33. name string
  34. pricing *AWSPricing
  35. wantErr bool
  36. }{
  37. {
  38. name: "valid pricing data",
  39. pricing: &pricing,
  40. wantErr: false,
  41. },
  42. }
  43. for _, tt := range tests {
  44. t.Run(tt.name, func(t *testing.T) {
  45. f := NewFargatePricing()
  46. err := f.populatePricing(tt.pricing)
  47. if tt.wantErr {
  48. if err == nil {
  49. t.Errorf("populatePricing() expected error, got nil")
  50. }
  51. return
  52. }
  53. if err != nil {
  54. t.Errorf("populatePricing() unexpected error: %v", err)
  55. return
  56. }
  57. // Verify that regions were populated
  58. if len(f.regions) == 0 {
  59. t.Error("populatePricing() did not populate any regions")
  60. return
  61. }
  62. // Check that us-east-1 pricing was populated (from test data)
  63. usEast1, ok := f.regions["us-east-1"]
  64. if !ok {
  65. t.Error("populatePricing() did not populate us-east-1 region")
  66. return
  67. }
  68. // Verify all required usage types are present
  69. for _, usageType := range fargateUsageTypes {
  70. if price, ok := usEast1[usageType]; !ok {
  71. t.Errorf("populatePricing() missing usage type %s", usageType)
  72. } else if price <= 0 {
  73. t.Errorf("populatePricing() invalid price %f for usage type %s", price, usageType)
  74. }
  75. }
  76. // Test specific pricing values from test data
  77. for usageType, expectedPrice := range testRegionPricing {
  78. if actualPrice, ok := usEast1[usageType]; ok {
  79. if actualPrice != expectedPrice {
  80. t.Errorf("populatePricing() price mismatch for %s: expected %f, got %f", usageType, expectedPrice, actualPrice)
  81. }
  82. }
  83. }
  84. })
  85. }
  86. }
  87. func TestFargatePricing_GetHourlyPricing(t *testing.T) {
  88. // Create a Fargate pricing instance with test data
  89. f := NewFargatePricing()
  90. // Populate test pricing data for us-east-1
  91. f.regions["us-east-1"] = testRegionPricing
  92. tests := []struct {
  93. name string
  94. region string
  95. os string
  96. arch string
  97. expectedCPU float64
  98. expectedRAM float64
  99. expectedErr bool
  100. }{
  101. {
  102. name: "linux amd64",
  103. region: "us-east-1",
  104. os: "linux",
  105. arch: "amd64",
  106. expectedCPU: 0.0404800000,
  107. expectedRAM: 0.0044450000,
  108. expectedErr: false,
  109. },
  110. {
  111. name: "linux arm64",
  112. region: "us-east-1",
  113. os: "linux",
  114. arch: "arm64",
  115. expectedCPU: 0.0323800000,
  116. expectedRAM: 0.0035600000,
  117. expectedErr: false,
  118. },
  119. {
  120. name: "windows (any arch)",
  121. region: "us-east-1",
  122. os: "windows",
  123. arch: "amd64",
  124. expectedCPU: 0.0925520000, // CPU + License: 0.0465520000 + 0.0460000000
  125. expectedRAM: 0.0051117500,
  126. expectedErr: false,
  127. },
  128. {
  129. name: "unknown region",
  130. region: "unknown-region",
  131. os: "linux",
  132. arch: "amd64",
  133. expectedCPU: 0,
  134. expectedRAM: 0,
  135. expectedErr: true,
  136. },
  137. {
  138. name: "unknown os",
  139. region: "us-east-1",
  140. os: "macos",
  141. arch: "amd64",
  142. expectedCPU: 0,
  143. expectedRAM: 0,
  144. expectedErr: true,
  145. },
  146. {
  147. name: "unknown arch for linux",
  148. region: "us-east-1",
  149. os: "linux",
  150. arch: "unknown",
  151. expectedCPU: 0,
  152. expectedRAM: 0,
  153. expectedErr: true,
  154. },
  155. }
  156. for _, tt := range tests {
  157. t.Run(tt.name, func(t *testing.T) {
  158. cpu, memory, err := f.GetHourlyPricing(tt.region, tt.os, tt.arch)
  159. if tt.expectedErr {
  160. if err == nil {
  161. t.Errorf("GetHourlyPricing() expected error, got nil")
  162. }
  163. return
  164. }
  165. if err != nil {
  166. t.Errorf("GetHourlyPricing() unexpected error: %v", err)
  167. return
  168. }
  169. if cpu != tt.expectedCPU {
  170. t.Errorf("GetHourlyPricing() CPU price mismatch: expected %f, got %f", tt.expectedCPU, cpu)
  171. }
  172. if memory != tt.expectedRAM {
  173. t.Errorf("GetHourlyPricing() RAM price mismatch: expected %f, got %f", tt.expectedRAM, memory)
  174. }
  175. })
  176. }
  177. }
  178. func TestFargateRegionPricing_Validate(t *testing.T) {
  179. tests := []struct {
  180. name string
  181. pricing FargateRegionPricing
  182. wantErr bool
  183. }{
  184. {
  185. name: "valid complete pricing",
  186. pricing: FargateRegionPricing{
  187. usageTypeFargateLinuxX86CPU: 0.04048,
  188. usageTypeFargateLinuxX86RAM: 0.004445,
  189. usageTypeFargateLinuxArmCPU: 0.03238,
  190. usageTypeFargateLinuxArmRAM: 0.00356,
  191. usageTypeFargateWindowsCPU: 0.046552,
  192. usageTypeFargateWindowsLicense: 0.046,
  193. usageTypeFargateWindowsRAM: 0.00511175,
  194. },
  195. wantErr: false,
  196. },
  197. {
  198. name: "missing linux x86 CPU",
  199. pricing: FargateRegionPricing{
  200. usageTypeFargateLinuxX86RAM: 0.004445,
  201. usageTypeFargateLinuxArmCPU: 0.03238,
  202. usageTypeFargateLinuxArmRAM: 0.00356,
  203. usageTypeFargateWindowsCPU: 0.046552,
  204. usageTypeFargateWindowsLicense: 0.046,
  205. usageTypeFargateWindowsRAM: 0.00511175,
  206. },
  207. wantErr: true,
  208. },
  209. {
  210. name: "missing linux x86 RAM",
  211. pricing: FargateRegionPricing{
  212. usageTypeFargateLinuxX86CPU: 0.04048,
  213. usageTypeFargateLinuxArmCPU: 0.03238,
  214. usageTypeFargateLinuxArmRAM: 0.00356,
  215. usageTypeFargateWindowsCPU: 0.046552,
  216. usageTypeFargateWindowsLicense: 0.046,
  217. usageTypeFargateWindowsRAM: 0.00511175,
  218. },
  219. wantErr: true,
  220. },
  221. {
  222. name: "missing windows license",
  223. pricing: FargateRegionPricing{
  224. usageTypeFargateLinuxX86CPU: 0.04048,
  225. usageTypeFargateLinuxX86RAM: 0.004445,
  226. usageTypeFargateLinuxArmCPU: 0.03238,
  227. usageTypeFargateLinuxArmRAM: 0.00356,
  228. usageTypeFargateWindowsCPU: 0.046552,
  229. usageTypeFargateWindowsRAM: 0.00511175,
  230. },
  231. wantErr: true,
  232. },
  233. {
  234. name: "empty pricing",
  235. pricing: FargateRegionPricing{},
  236. wantErr: true,
  237. },
  238. }
  239. for _, tt := range tests {
  240. t.Run(tt.name, func(t *testing.T) {
  241. err := tt.pricing.Validate()
  242. if tt.wantErr && err == nil {
  243. t.Errorf("Validate() expected error, got nil")
  244. }
  245. if !tt.wantErr && err != nil {
  246. t.Errorf("Validate() unexpected error: %v", err)
  247. }
  248. })
  249. }
  250. }
  251. func TestFargatePricing_Initialize(t *testing.T) {
  252. // Load test data
  253. testDataPath := "testdata/ecs-pricing-us-east-1.json"
  254. data, err := os.ReadFile(testDataPath)
  255. if err != nil {
  256. t.Fatalf("Failed to read test data: %v", err)
  257. }
  258. // Create a test HTTP server that serves the pricing data
  259. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  260. w.Header().Set("Content-Type", "application/json")
  261. w.WriteHeader(http.StatusOK)
  262. _, _ = w.Write(data)
  263. }))
  264. defer server.Close()
  265. // Set up test environment variable to use our test server
  266. t.Setenv("AWS_ECS_PRICING_URL", server.URL)
  267. tests := []struct {
  268. name string
  269. nodeList []*clustercache.Node
  270. wantErr bool
  271. }{
  272. {
  273. name: "successful initialization",
  274. nodeList: []*clustercache.Node{
  275. {
  276. Name: "test-node",
  277. Labels: map[string]string{
  278. "topology.kubernetes.io/region": "us-east-1",
  279. },
  280. },
  281. },
  282. wantErr: false,
  283. },
  284. {
  285. name: "empty node list",
  286. nodeList: []*clustercache.Node{},
  287. wantErr: false,
  288. },
  289. }
  290. for _, tt := range tests {
  291. t.Run(tt.name, func(t *testing.T) {
  292. f := NewFargatePricing()
  293. err := f.Initialize(tt.nodeList)
  294. if tt.wantErr {
  295. if err == nil {
  296. t.Errorf("Initialize() expected error, got nil")
  297. }
  298. return
  299. }
  300. if err != nil {
  301. t.Errorf("Initialize() unexpected error: %v", err)
  302. return
  303. }
  304. // Verify that regions were populated
  305. if len(f.regions) == 0 {
  306. t.Error("Initialize() did not populate any regions")
  307. return
  308. }
  309. // Check that us-east-1 pricing was populated (from test data)
  310. usEast1, ok := f.regions["us-east-1"]
  311. if !ok {
  312. t.Error("Initialize() did not populate us-east-1 region")
  313. return
  314. }
  315. // Verify all required usage types are present
  316. for _, usageType := range fargateUsageTypes {
  317. if price, ok := usEast1[usageType]; !ok {
  318. t.Errorf("Initialize() missing usage type %s", usageType)
  319. } else if price <= 0 {
  320. t.Errorf("Initialize() invalid price %f for usage type %s", price, usageType)
  321. }
  322. }
  323. })
  324. }
  325. }
  326. func TestFargatePricing_Initialize_HTTPError(t *testing.T) {
  327. // Create a test HTTP server that returns an error
  328. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  329. w.WriteHeader(http.StatusInternalServerError)
  330. }))
  331. defer server.Close()
  332. // Set up test environment variable to use our test server
  333. t.Setenv("AWS_ECS_PRICING_URL", server.URL)
  334. f := NewFargatePricing()
  335. nodeList := []*clustercache.Node{
  336. {
  337. Name: "test-node",
  338. Labels: map[string]string{
  339. "topology.kubernetes.io/region": "us-east-1",
  340. },
  341. },
  342. }
  343. err := f.Initialize(nodeList)
  344. if err == nil {
  345. t.Error("Initialize() expected error for HTTP 500, got nil")
  346. }
  347. }
  348. func TestFargatePricing_Initialize_InvalidJSON(t *testing.T) {
  349. // Create a test HTTP server that returns invalid JSON
  350. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  351. w.Header().Set("Content-Type", "application/json")
  352. w.WriteHeader(http.StatusOK)
  353. _, _ = w.Write([]byte("invalid json"))
  354. }))
  355. defer server.Close()
  356. // Set up test environment variable to use our test server
  357. t.Setenv("AWS_ECS_PRICING_URL", server.URL)
  358. f := NewFargatePricing()
  359. nodeList := []*clustercache.Node{
  360. {
  361. Name: "test-node",
  362. Labels: map[string]string{
  363. "topology.kubernetes.io/region": "us-east-1",
  364. },
  365. },
  366. }
  367. err := f.Initialize(nodeList)
  368. if err == nil {
  369. t.Error("Initialize() expected error for invalid JSON, got nil")
  370. }
  371. }
  372. func TestFargatePricing_getPricingURL(t *testing.T) {
  373. tests := []struct {
  374. name string
  375. nodeList []*clustercache.Node
  376. envVar string
  377. expected string
  378. }{
  379. {
  380. name: "with environment variable override",
  381. nodeList: []*clustercache.Node{
  382. {
  383. Name: "test-node",
  384. Labels: map[string]string{
  385. "topology.kubernetes.io/region": "us-east-1",
  386. },
  387. },
  388. },
  389. envVar: "https://custom-pricing-url.com",
  390. expected: "https://custom-pricing-url.com",
  391. },
  392. {
  393. name: "without environment variable - single region",
  394. nodeList: []*clustercache.Node{
  395. {
  396. Name: "test-node",
  397. Labels: map[string]string{
  398. "topology.kubernetes.io/region": "us-west-2",
  399. },
  400. },
  401. },
  402. envVar: "",
  403. expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonECS/current/us-west-2/index.json",
  404. },
  405. {
  406. name: "without environment variable - Chinese region",
  407. nodeList: []*clustercache.Node{
  408. {
  409. Name: "test-node",
  410. Labels: map[string]string{
  411. "topology.kubernetes.io/region": "cn-north-1",
  412. },
  413. },
  414. },
  415. envVar: "",
  416. expected: "https://pricing.cn-north-1.amazonaws.com.cn/offers/v1.0/cn/AmazonECS/current/cn-north-1/index.json",
  417. },
  418. {
  419. name: "without environment variable - empty node list",
  420. nodeList: []*clustercache.Node{},
  421. envVar: "",
  422. expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonECS/current/index.json",
  423. },
  424. }
  425. for _, tt := range tests {
  426. t.Run(tt.name, func(t *testing.T) {
  427. if tt.envVar != "" {
  428. t.Setenv("AWS_ECS_PRICING_URL", tt.envVar)
  429. } else {
  430. t.Setenv("AWS_ECS_PRICING_URL", "")
  431. }
  432. f := NewFargatePricing()
  433. result := f.getPricingURL(tt.nodeList)
  434. if result != tt.expected {
  435. t.Errorf("getPricingURL() = %v, expected %v", result, tt.expected)
  436. }
  437. })
  438. }
  439. }
  440. // TestFargatePricing_ValidateAWSPricingFormat validates that the actual AWS pricing API
  441. // returns data in the expected format. This test is skipped by default and only runs
  442. // when INTEGRATION=true to avoid hitting AWS APIs in regular CI runs.
  443. func TestFargatePricing_ValidateAWSPricingFormat(t *testing.T) {
  444. if os.Getenv("INTEGRATION") != "true" {
  445. t.Skip("Skipping integration test. Set INTEGRATION=true to run.")
  446. }
  447. nodes := []*clustercache.Node{
  448. {
  449. Labels: map[string]string{
  450. "topology.kubernetes.io/region": "us-east-1",
  451. },
  452. },
  453. }
  454. url := getPricingListURL("AmazonECS", nodes)
  455. t.Logf("Testing AWS pricing URL: %s", url)
  456. client := &http.Client{Timeout: 30 * time.Second}
  457. resp, err := client.Get(url)
  458. if err != nil {
  459. t.Fatalf("Failed to fetch pricing data: %v", err)
  460. }
  461. defer resp.Body.Close()
  462. if resp.StatusCode != http.StatusOK {
  463. t.Fatalf("Unexpected status code: %d", resp.StatusCode)
  464. }
  465. var pricing AWSPricing
  466. if err := json.NewDecoder(resp.Body).Decode(&pricing); err != nil {
  467. t.Fatalf("Failed to decode pricing data - AWS format may have changed: %v", err)
  468. }
  469. if len(pricing.Products) == 0 {
  470. t.Fatal("Expected products in pricing data, got none - AWS format may have changed")
  471. }
  472. if len(pricing.Terms.OnDemand) == 0 {
  473. t.Fatal("Expected OnDemand terms in pricing data, got none - AWS format may have changed")
  474. }
  475. t.Logf("✓ AWS pricing format validated: %d products, %d OnDemand terms",
  476. len(pricing.Products), len(pricing.Terms.OnDemand))
  477. }