server_test.go 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540
  1. package mcp
  2. import (
  3. "context"
  4. "io"
  5. "testing"
  6. "time"
  7. "github.com/opencost/opencost/core/pkg/autocomplete"
  8. "github.com/opencost/opencost/core/pkg/clustercache"
  9. "github.com/opencost/opencost/core/pkg/opencost"
  10. models "github.com/opencost/opencost/pkg/cloud/models"
  11. "github.com/opencost/opencost/pkg/cloudcost"
  12. "github.com/opencost/opencost/pkg/costmodel"
  13. "github.com/stretchr/testify/assert"
  14. "github.com/stretchr/testify/require"
  15. )
  16. func TestQueryTypeConstants(t *testing.T) {
  17. assert.Equal(t, QueryType("allocation"), AllocationQueryType)
  18. assert.Equal(t, QueryType("asset"), AssetQueryType)
  19. assert.Equal(t, QueryType("cloudcost"), CloudCostQueryType)
  20. }
  21. func TestAllocationQueryStruct(t *testing.T) {
  22. query := AllocationQuery{
  23. Step: 1 * time.Hour,
  24. Accumulate: true,
  25. ShareIdle: true,
  26. Aggregate: "namespace",
  27. IncludeIdle: true,
  28. IdleByNode: true,
  29. IncludeProportionalAssetResourceCosts: true,
  30. IncludeAggregatedMetadata: true,
  31. ShareLB: true,
  32. }
  33. assert.Equal(t, 1*time.Hour, query.Step)
  34. assert.True(t, query.Accumulate)
  35. assert.True(t, query.ShareIdle)
  36. assert.Equal(t, "namespace", query.Aggregate)
  37. assert.True(t, query.IncludeIdle)
  38. assert.True(t, query.IdleByNode)
  39. assert.True(t, query.IncludeProportionalAssetResourceCosts)
  40. assert.True(t, query.IncludeAggregatedMetadata)
  41. assert.True(t, query.ShareLB)
  42. }
  43. func TestAssetQueryStruct(t *testing.T) {
  44. query := AssetQuery{}
  45. // AssetQuery is currently empty, just test that it can be created
  46. assert.NotNil(t, query)
  47. }
  48. func TestCloudCostQueryStruct(t *testing.T) {
  49. query := CloudCostQuery{
  50. Aggregate: "provider,service",
  51. Accumulate: "day",
  52. Filter: "provider=aws",
  53. Provider: "aws",
  54. Service: "ec2",
  55. Category: "compute",
  56. Region: "us-east-1",
  57. AccountID: "123456789",
  58. }
  59. assert.Equal(t, "provider,service", query.Aggregate)
  60. assert.Equal(t, "day", query.Accumulate)
  61. assert.Equal(t, "provider=aws", query.Filter)
  62. assert.Equal(t, "aws", query.Provider)
  63. assert.Equal(t, "ec2", query.Service)
  64. assert.Equal(t, "compute", query.Category)
  65. assert.Equal(t, "us-east-1", query.Region)
  66. assert.Equal(t, "123456789", query.AccountID)
  67. }
  68. func TestMCPRequestStruct(t *testing.T) {
  69. request := MCPRequest{
  70. SessionID: "test-session-123",
  71. Query: &OpenCostQueryRequest{
  72. QueryType: AllocationQueryType,
  73. Window: "24h",
  74. AllocationParams: &AllocationQuery{
  75. Step: 1 * time.Hour,
  76. Accumulate: true,
  77. ShareIdle: true,
  78. },
  79. },
  80. }
  81. assert.Equal(t, "test-session-123", request.SessionID)
  82. assert.NotNil(t, request.Query)
  83. assert.Equal(t, AllocationQueryType, request.Query.QueryType)
  84. assert.Equal(t, "24h", request.Query.Window)
  85. assert.NotNil(t, request.Query.AllocationParams)
  86. assert.Equal(t, 1*time.Hour, request.Query.AllocationParams.Step)
  87. assert.True(t, request.Query.AllocationParams.Accumulate)
  88. assert.True(t, request.Query.AllocationParams.ShareIdle)
  89. }
  90. func TestMCPResponseStruct(t *testing.T) {
  91. response := MCPResponse{
  92. Data: "test-data",
  93. QueryInfo: QueryMetadata{
  94. QueryID: "query-123",
  95. Timestamp: time.Now(),
  96. ProcessingTime: 100 * time.Millisecond,
  97. },
  98. }
  99. assert.Equal(t, "test-data", response.Data)
  100. assert.Equal(t, "query-123", response.QueryInfo.QueryID)
  101. assert.NotZero(t, response.QueryInfo.Timestamp)
  102. assert.Equal(t, 100*time.Millisecond, response.QueryInfo.ProcessingTime)
  103. }
  104. func TestQueryMetadataStruct(t *testing.T) {
  105. metadata := QueryMetadata{
  106. QueryID: "query-456",
  107. Timestamp: time.Now(),
  108. ProcessingTime: 250 * time.Millisecond,
  109. }
  110. assert.Equal(t, "query-456", metadata.QueryID)
  111. assert.NotZero(t, metadata.Timestamp)
  112. assert.Equal(t, 250*time.Millisecond, metadata.ProcessingTime)
  113. }
  114. func TestOpenCostQueryRequestStruct(t *testing.T) {
  115. request := OpenCostQueryRequest{
  116. QueryType: AssetQueryType,
  117. Window: "7d",
  118. AssetParams: &AssetQuery{},
  119. }
  120. assert.Equal(t, AssetQueryType, request.QueryType)
  121. assert.Equal(t, "7d", request.Window)
  122. assert.NotNil(t, request.AssetParams)
  123. }
  124. // Test helper functions
  125. func createTestAllocation(name string) *Allocation {
  126. now := time.Now()
  127. return &Allocation{
  128. Name: name,
  129. CPUCost: 10.0,
  130. RAMCost: 5.0,
  131. GPUCost: 0.0,
  132. PVCost: 2.0,
  133. NetworkCost: 1.0,
  134. SharedCost: 0.5,
  135. ExternalCost: 0.0,
  136. TotalCost: 18.5,
  137. CPUCoreHours: 100.0,
  138. RAMByteHours: 5000000000.0,
  139. GPUHours: 0.0,
  140. PVByteHours: 2000000000.0,
  141. Start: now.Add(-24 * time.Hour),
  142. End: now,
  143. }
  144. }
  145. func createTestAsset(name string) *Asset {
  146. now := time.Now()
  147. return &Asset{
  148. Type: "node",
  149. Properties: AssetProperties{
  150. Category: "compute",
  151. Provider: "aws",
  152. Name: name,
  153. },
  154. CPUCost: 50.0,
  155. RAMCost: 25.0,
  156. GPUCost: 100.0,
  157. TotalCost: 175.0,
  158. CPUCoreHours: 500.0,
  159. RAMByteHours: 25000000000.0,
  160. GPUHours: 50.0,
  161. Start: now.Add(-24 * time.Hour),
  162. End: now,
  163. }
  164. }
  165. func createTestCloudCost(name string) *CloudCost {
  166. now := time.Now()
  167. return &CloudCost{
  168. Properties: CloudCostProperties{
  169. Provider: "aws",
  170. Service: "ec2",
  171. },
  172. Window: TimeWindow{
  173. Start: now.Add(-24 * time.Hour),
  174. End: now,
  175. },
  176. ListCost: CostMetric{
  177. Cost: 100.0,
  178. KubernetesPercent: 80.0,
  179. },
  180. NetCost: CostMetric{
  181. Cost: 95.0,
  182. KubernetesPercent: 80.0,
  183. },
  184. }
  185. }
  186. // Test MCP server response structures
  187. func TestAllocationResponseStruct(t *testing.T) {
  188. allocation := createTestAllocation("test-namespace")
  189. allocationSet := &AllocationSet{
  190. Name: "test-namespace",
  191. Properties: map[string]string{
  192. "namespace": "test-namespace",
  193. },
  194. Allocations: []*Allocation{allocation},
  195. }
  196. response := AllocationResponse{
  197. Allocations: map[string]*AllocationSet{
  198. "test-namespace": allocationSet,
  199. },
  200. }
  201. require.NotNil(t, response.Allocations)
  202. assert.Len(t, response.Allocations, 1)
  203. assert.Contains(t, response.Allocations, "test-namespace")
  204. allocSet := response.Allocations["test-namespace"]
  205. assert.Equal(t, "test-namespace", allocSet.Name)
  206. assert.Len(t, allocSet.Allocations, 1)
  207. alloc := allocSet.Allocations[0]
  208. assert.Equal(t, "test-namespace", alloc.Name)
  209. assert.Equal(t, 10.0, alloc.CPUCost)
  210. assert.Equal(t, 5.0, alloc.RAMCost)
  211. assert.Equal(t, 18.5, alloc.TotalCost)
  212. }
  213. func TestAssetResponseStruct(t *testing.T) {
  214. asset := createTestAsset("test-node")
  215. assetSet := &AssetSet{
  216. Name: "test-node",
  217. Assets: []*Asset{asset},
  218. }
  219. response := AssetResponse{
  220. Assets: map[string]*AssetSet{
  221. "test-node": assetSet,
  222. },
  223. }
  224. require.NotNil(t, response.Assets)
  225. assert.Len(t, response.Assets, 1)
  226. assert.Contains(t, response.Assets, "test-node")
  227. assetSetResult := response.Assets["test-node"]
  228. assert.Equal(t, "test-node", assetSetResult.Name)
  229. assert.Len(t, assetSetResult.Assets, 1)
  230. assetResult := assetSetResult.Assets[0]
  231. assert.Equal(t, "node", assetResult.Type)
  232. assert.Equal(t, 50.0, assetResult.CPUCost)
  233. assert.Equal(t, 25.0, assetResult.RAMCost)
  234. assert.Equal(t, 100.0, assetResult.GPUCost)
  235. assert.Equal(t, 175.0, assetResult.TotalCost)
  236. }
  237. func TestCloudCostResponseStruct(t *testing.T) {
  238. cloudCost := createTestCloudCost("aws-ec2")
  239. cloudCostSet := &CloudCostSet{
  240. Name: "aws-ec2",
  241. CloudCosts: []*CloudCost{cloudCost},
  242. Window: &TimeWindow{
  243. Start: time.Now().Add(-24 * time.Hour),
  244. End: time.Now(),
  245. },
  246. }
  247. response := CloudCostResponse{
  248. CloudCosts: map[string]*CloudCostSet{
  249. "aws-ec2": cloudCostSet,
  250. },
  251. Summary: &CloudCostSummary{
  252. TotalNetCost: 95.0,
  253. TotalAmortizedCost: 90.0,
  254. TotalInvoicedCost: 100.0,
  255. KubernetesPercent: 80.0,
  256. },
  257. }
  258. require.NotNil(t, response.CloudCosts)
  259. assert.Len(t, response.CloudCosts, 1)
  260. assert.Contains(t, response.CloudCosts, "aws-ec2")
  261. costSet := response.CloudCosts["aws-ec2"]
  262. assert.Equal(t, "aws-ec2", costSet.Name)
  263. assert.Len(t, costSet.CloudCosts, 1)
  264. cost := costSet.CloudCosts[0]
  265. assert.Equal(t, "aws", cost.Properties.Provider)
  266. assert.Equal(t, "ec2", cost.Properties.Service)
  267. assert.Equal(t, 100.0, cost.ListCost.Cost)
  268. assert.Equal(t, 95.0, cost.NetCost.Cost)
  269. require.NotNil(t, response.Summary)
  270. assert.Equal(t, 95.0, response.Summary.TotalNetCost)
  271. assert.Equal(t, 80.0, response.Summary.KubernetesPercent)
  272. }
  273. // Test allocation set functionality
  274. func TestAllocationSetTotalCost(t *testing.T) {
  275. alloc1 := createTestAllocation("alloc1")
  276. alloc1.TotalCost = 10.0
  277. alloc2 := createTestAllocation("alloc2")
  278. alloc2.TotalCost = 15.0
  279. allocSet := &AllocationSet{
  280. Name: "test-set",
  281. Allocations: []*Allocation{alloc1, alloc2},
  282. }
  283. totalCost := allocSet.TotalCost()
  284. assert.Equal(t, 25.0, totalCost)
  285. }
  286. // Test asset properties
  287. func TestAssetProperties(t *testing.T) {
  288. props := AssetProperties{
  289. Category: "compute",
  290. Provider: "aws",
  291. Account: "123456789",
  292. Project: "my-project",
  293. Service: "ec2",
  294. Cluster: "prod-cluster",
  295. Name: "worker-node-1",
  296. ProviderID: "i-1234567890abcdef0",
  297. }
  298. assert.Equal(t, "compute", props.Category)
  299. assert.Equal(t, "aws", props.Provider)
  300. assert.Equal(t, "123456789", props.Account)
  301. assert.Equal(t, "my-project", props.Project)
  302. assert.Equal(t, "ec2", props.Service)
  303. assert.Equal(t, "prod-cluster", props.Cluster)
  304. assert.Equal(t, "worker-node-1", props.Name)
  305. assert.Equal(t, "i-1234567890abcdef0", props.ProviderID)
  306. }
  307. // Test cloud cost properties
  308. func TestCloudCostProperties(t *testing.T) {
  309. props := CloudCostProperties{
  310. ProviderID: "i-1234567890abcdef0",
  311. Provider: "aws",
  312. AccountID: "123456789",
  313. AccountName: "my-account",
  314. InvoiceEntityID: "entity-123",
  315. InvoiceEntityName: "My Company",
  316. RegionID: "us-east-1",
  317. AvailabilityZone: "us-east-1a",
  318. Service: "ec2",
  319. Category: "compute",
  320. Labels: map[string]string{
  321. "environment": "production",
  322. "team": "platform",
  323. },
  324. }
  325. assert.Equal(t, "i-1234567890abcdef0", props.ProviderID)
  326. assert.Equal(t, "aws", props.Provider)
  327. assert.Equal(t, "123456789", props.AccountID)
  328. assert.Equal(t, "my-account", props.AccountName)
  329. assert.Equal(t, "entity-123", props.InvoiceEntityID)
  330. assert.Equal(t, "My Company", props.InvoiceEntityName)
  331. assert.Equal(t, "us-east-1", props.RegionID)
  332. assert.Equal(t, "us-east-1a", props.AvailabilityZone)
  333. assert.Equal(t, "ec2", props.Service)
  334. assert.Equal(t, "compute", props.Category)
  335. assert.Equal(t, "production", props.Labels["environment"])
  336. assert.Equal(t, "platform", props.Labels["team"])
  337. }
  338. // Test cost metric
  339. func TestCostMetric(t *testing.T) {
  340. metric := CostMetric{
  341. Cost: 100.0,
  342. KubernetesPercent: 80.0,
  343. }
  344. assert.Equal(t, 100.0, metric.Cost)
  345. assert.Equal(t, 80.0, metric.KubernetesPercent)
  346. }
  347. // Test time window
  348. func TestTimeWindow(t *testing.T) {
  349. now := time.Now()
  350. window := TimeWindow{
  351. Start: now.Add(-24 * time.Hour),
  352. End: now,
  353. }
  354. assert.True(t, window.Start.Before(window.End))
  355. assert.Equal(t, 24*time.Hour, window.End.Sub(window.Start))
  356. }
  357. // Test node overhead
  358. func TestNodeOverhead(t *testing.T) {
  359. overhead := NodeOverhead{
  360. RamOverheadFraction: 0.1,
  361. CpuOverheadFraction: 0.05,
  362. OverheadCostFraction: 0.15,
  363. }
  364. assert.Equal(t, 0.1, overhead.RamOverheadFraction)
  365. assert.Equal(t, 0.05, overhead.CpuOverheadFraction)
  366. assert.Equal(t, 0.15, overhead.OverheadCostFraction)
  367. }
  368. // Test asset breakdown
  369. func TestAssetBreakdown(t *testing.T) {
  370. breakdown := AssetBreakdown{
  371. Idle: 10.0,
  372. Other: 5.0,
  373. System: 15.0,
  374. User: 70.0,
  375. }
  376. assert.Equal(t, 10.0, breakdown.Idle)
  377. assert.Equal(t, 5.0, breakdown.Other)
  378. assert.Equal(t, 15.0, breakdown.System)
  379. assert.Equal(t, 70.0, breakdown.User)
  380. }
  381. // Test cloud cost summary
  382. func TestCloudCostSummary(t *testing.T) {
  383. summary := CloudCostSummary{
  384. TotalNetCost: 1000.0,
  385. TotalAmortizedCost: 950.0,
  386. TotalInvoicedCost: 1100.0,
  387. KubernetesPercent: 85.0,
  388. ProviderBreakdown: map[string]float64{
  389. "aws": 800.0,
  390. "gcp": 200.0,
  391. },
  392. ServiceBreakdown: map[string]float64{
  393. "ec2": 600.0,
  394. "s3": 200.0,
  395. "rds": 200.0,
  396. },
  397. RegionBreakdown: map[string]float64{
  398. "us-east-1": 600.0,
  399. "us-west-2": 400.0,
  400. },
  401. }
  402. assert.Equal(t, 1000.0, summary.TotalNetCost)
  403. assert.Equal(t, 950.0, summary.TotalAmortizedCost)
  404. assert.Equal(t, 1100.0, summary.TotalInvoicedCost)
  405. assert.Equal(t, 85.0, summary.KubernetesPercent)
  406. assert.Equal(t, 800.0, summary.ProviderBreakdown["aws"])
  407. assert.Equal(t, 200.0, summary.ProviderBreakdown["gcp"])
  408. assert.Equal(t, 600.0, summary.ServiceBreakdown["ec2"])
  409. assert.Equal(t, 200.0, summary.ServiceBreakdown["s3"])
  410. assert.Equal(t, 600.0, summary.RegionBreakdown["us-east-1"])
  411. assert.Equal(t, 400.0, summary.RegionBreakdown["us-west-2"])
  412. }
  413. // Test default values
  414. func TestAllocationQueryDefaultValues(t *testing.T) {
  415. query := AllocationQuery{}
  416. // Test default values
  417. assert.Equal(t, time.Duration(0), query.Step)
  418. assert.False(t, query.Accumulate)
  419. assert.False(t, query.ShareIdle)
  420. assert.Empty(t, query.Aggregate)
  421. assert.False(t, query.IncludeIdle)
  422. assert.False(t, query.IdleByNode)
  423. assert.False(t, query.IncludeProportionalAssetResourceCosts)
  424. assert.False(t, query.IncludeAggregatedMetadata)
  425. assert.False(t, query.ShareLB)
  426. }
  427. func TestCloudCostQueryDefaultValues(t *testing.T) {
  428. query := CloudCostQuery{}
  429. // Test default values
  430. assert.Empty(t, query.Aggregate)
  431. assert.Empty(t, query.Accumulate)
  432. assert.Empty(t, query.Filter)
  433. assert.Empty(t, query.Provider)
  434. assert.Empty(t, query.Service)
  435. assert.Empty(t, query.Category)
  436. assert.Empty(t, query.Region)
  437. assert.Empty(t, query.AccountID)
  438. }
  439. // Test edge cases
  440. func TestEdgeCases(t *testing.T) {
  441. t.Run("zero duration step", func(t *testing.T) {
  442. query := AllocationQuery{
  443. Step: 0,
  444. }
  445. assert.Equal(t, time.Duration(0), query.Step)
  446. })
  447. t.Run("negative duration step", func(t *testing.T) {
  448. query := AllocationQuery{
  449. Step: -1 * time.Hour,
  450. }
  451. assert.Equal(t, -1*time.Hour, query.Step)
  452. })
  453. t.Run("very large duration step", func(t *testing.T) {
  454. query := AllocationQuery{
  455. Step: 365 * 24 * time.Hour, // 1 year
  456. }
  457. assert.Equal(t, 365*24*time.Hour, query.Step)
  458. })
  459. t.Run("empty aggregate string", func(t *testing.T) {
  460. query := AllocationQuery{
  461. Aggregate: "",
  462. }
  463. assert.Empty(t, query.Aggregate)
  464. })
  465. t.Run("comma separated aggregate", func(t *testing.T) {
  466. query := AllocationQuery{
  467. Aggregate: "namespace,cluster,node",
  468. }
  469. assert.Equal(t, "namespace,cluster,node", query.Aggregate)
  470. })
  471. }
  472. // dummyQuerier captures the last QueryRequest it received
  473. type dummyQuerier struct {
  474. last cloudcost.QueryRequest
  475. }
  476. func (dq *dummyQuerier) Query(_ context.Context, req cloudcost.QueryRequest) (*opencost.CloudCostSetRange, error) {
  477. dq.last = req
  478. // Return empty set range
  479. ccsr, _ := opencost.NewCloudCostSetRange(time.Now().Add(-24*time.Hour), time.Now(), opencost.AccumulateOptionDay, "")
  480. return ccsr, nil
  481. }
  482. func (dq *dummyQuerier) QueryCloudCostAutocomplete(_ context.Context, _ autocomplete.Request) (*autocomplete.Response, error) {
  483. return &autocomplete.Response{Data: []string{}}, nil
  484. }
  485. func TestBuildCloudCostQueryRequest_AccumulateParsing(t *testing.T) {
  486. s := &MCPServer{}
  487. req := cloudcost.QueryRequest{}
  488. params := &CloudCostQuery{
  489. Aggregate: "provider,service",
  490. Accumulate: "week",
  491. }
  492. out := s.buildCloudCostQueryRequest(req, params)
  493. assert.Equal(t, []string{"provider", "service"}, out.AggregateBy)
  494. assert.NotEqual(t, opencost.AccumulateOptionNone, out.Accumulate)
  495. }
  496. func TestBuildCloudCostQueryRequest_FilterString(t *testing.T) {
  497. s := &MCPServer{}
  498. req := cloudcost.QueryRequest{}
  499. params := &CloudCostQuery{
  500. Filter: `provider:"gcp" and service:"Compute Engine"`,
  501. }
  502. out := s.buildCloudCostQueryRequest(req, params)
  503. assert.NotNil(t, out.Filter)
  504. }
  505. func TestBuildFilterFromParams_SupportedFieldsOnly(t *testing.T) {
  506. s := &MCPServer{}
  507. params := &CloudCostQuery{
  508. Provider: "gcp",
  509. ProviderID: "cluster-1",
  510. Service: "Compute Engine",
  511. Category: "compute",
  512. AccountID: "acct-123",
  513. InvoiceEntityID: "inv-456",
  514. Region: "us-central1", // intentionally set; ignored by builder
  515. Labels: map[string]string{
  516. "goog-k8s-cluster-name": "cluster-1",
  517. },
  518. }
  519. f := s.buildFilterFromParams(params)
  520. assert.NotNil(t, f)
  521. }
  522. func TestBuildFilterFromParams_LabelOnly(t *testing.T) {
  523. s := &MCPServer{}
  524. params := &CloudCostQuery{
  525. Labels: map[string]string{"environment": "prod"},
  526. }
  527. f := s.buildFilterFromParams(params)
  528. assert.NotNil(t, f)
  529. }
  530. func TestQueryCloudCosts_QuerierCapture(t *testing.T) {
  531. dq := &dummyQuerier{}
  532. s := &MCPServer{cloudQuerier: dq}
  533. req := &OpenCostQueryRequest{
  534. QueryType: CloudCostQueryType,
  535. Window: "5d",
  536. CloudCostParams: &CloudCostQuery{
  537. Aggregate: "provider,service",
  538. Accumulate: "week",
  539. Provider: "gcp",
  540. },
  541. }
  542. _, err := s.QueryCloudCosts(context.Background(), req)
  543. require.NoError(t, err)
  544. assert.Equal(t, []string{"provider", "service"}, dq.last.AggregateBy)
  545. assert.NotEqual(t, opencost.AccumulateOptionNone, dq.last.Accumulate)
  546. }
  547. // ---- Tests for MCP server end-to-end behavior ----
  548. func TestProcessMCPRequest_CloudCostDispatch(t *testing.T) {
  549. dq := &dummyQuerier{}
  550. s := &MCPServer{cloudQuerier: dq}
  551. req := &MCPRequest{
  552. Query: &OpenCostQueryRequest{
  553. QueryType: CloudCostQueryType,
  554. Window: "3d",
  555. CloudCostParams: &CloudCostQuery{
  556. Aggregate: "provider",
  557. Accumulate: "day",
  558. Provider: "gcp",
  559. },
  560. },
  561. }
  562. resp, err := s.ProcessMCPRequest(context.Background(), req)
  563. require.NoError(t, err)
  564. require.NotNil(t, resp)
  565. require.NotNil(t, resp.Data)
  566. }
  567. func TestProcessMCPRequest_UnsupportedType(t *testing.T) {
  568. s := &MCPServer{}
  569. req := &MCPRequest{
  570. Query: &OpenCostQueryRequest{
  571. QueryType: QueryType("unknown"),
  572. Window: "1d",
  573. },
  574. }
  575. _, err := s.ProcessMCPRequest(context.Background(), req)
  576. require.Error(t, err)
  577. }
  578. func TestProcessMCPRequest_ValidationError(t *testing.T) {
  579. s := &MCPServer{}
  580. // Missing window
  581. req := &MCPRequest{
  582. Query: &OpenCostQueryRequest{
  583. QueryType: CloudCostQueryType,
  584. Window: "",
  585. },
  586. }
  587. _, err := s.ProcessMCPRequest(context.Background(), req)
  588. require.Error(t, err)
  589. }
  590. // ---- Additional comprehensive tests for missing functionality ----
  591. func TestNewMCPServer(t *testing.T) {
  592. costModel := &costmodel.CostModel{}
  593. provider := &mockProvider{}
  594. cloudQuerier := &dummyQuerier{}
  595. server := NewMCPServer(costModel, provider, cloudQuerier)
  596. require.NotNil(t, server)
  597. assert.Equal(t, costModel, server.costModel)
  598. assert.Equal(t, provider, server.provider)
  599. assert.Equal(t, cloudQuerier, server.cloudQuerier)
  600. }
  601. // Mock provider for testing
  602. type mockProvider struct{}
  603. func (mp *mockProvider) GetConfig() (*models.CustomPricing, error) { return nil, nil }
  604. func (mp *mockProvider) AllNodePricing() (interface{}, error) { return nil, nil }
  605. func (mp *mockProvider) ClusterInfo() (map[string]string, error) { return nil, nil }
  606. func (mp *mockProvider) GetAddresses() ([]byte, error) { return nil, nil }
  607. func (mp *mockProvider) GetDisks() ([]byte, error) { return nil, nil }
  608. func (mp *mockProvider) GetOrphanedResources() ([]models.OrphanedResource, error) { return nil, nil }
  609. func (mp *mockProvider) NodePricing(models.Key) (*models.Node, models.PricingMetadata, error) {
  610. return nil, models.PricingMetadata{}, nil
  611. }
  612. func (mp *mockProvider) GpuPricing(map[string]string) (string, error) { return "", nil }
  613. func (mp *mockProvider) PVPricing(models.PVKey) (*models.PV, error) { return nil, nil }
  614. func (mp *mockProvider) NetworkPricing() (*models.Network, error) { return nil, nil }
  615. func (mp *mockProvider) LoadBalancerPricing() (*models.LoadBalancer, error) { return nil, nil }
  616. func (mp *mockProvider) DownloadPricingData() error { return nil }
  617. func (mp *mockProvider) GetKey(map[string]string, *clustercache.Node) models.Key { return nil }
  618. func (mp *mockProvider) GetPVKey(*clustercache.PersistentVolume, map[string]string, string) models.PVKey {
  619. return nil
  620. }
  621. func (mp *mockProvider) UpdateConfig(io.Reader, string) (*models.CustomPricing, error) {
  622. return nil, nil
  623. }
  624. func (mp *mockProvider) UpdateConfigFromConfigMap(map[string]string) (*models.CustomPricing, error) {
  625. return nil, nil
  626. }
  627. func (mp *mockProvider) GetManagementPlatform() (string, error) { return "", nil }
  628. func (mp *mockProvider) ApplyReservedInstancePricing(map[string]*models.Node) {}
  629. func (mp *mockProvider) ServiceAccountStatus() *models.ServiceAccountStatus { return nil }
  630. func (mp *mockProvider) PricingSourceStatus() map[string]*models.PricingSource { return nil }
  631. func (mp *mockProvider) ClusterManagementPricing() (string, float64, error) { return "", 0, nil }
  632. func (mp *mockProvider) CombinedDiscountForNode(string, bool, float64, float64) float64 { return 0 }
  633. func (mp *mockProvider) Regions() []string { return nil }
  634. func (mp *mockProvider) PricingSourceSummary() interface{} { return nil }
  635. func TestGenerateQueryID(t *testing.T) {
  636. // Test that generateQueryID returns a non-empty string
  637. id1 := generateQueryID()
  638. id2 := generateQueryID()
  639. assert.NotEmpty(t, id1)
  640. assert.NotEmpty(t, id2)
  641. assert.NotEqual(t, id1, id2) // Should be different each time
  642. assert.Contains(t, id1, "query-")
  643. }
  644. func TestTransformAllocationSet_NilInput(t *testing.T) {
  645. result := transformAllocationSet(nil)
  646. require.NotNil(t, result)
  647. assert.NotNil(t, result.Allocations)
  648. assert.Len(t, result.Allocations, 0)
  649. }
  650. func TestTransformAllocationSet_EmptyInput(t *testing.T) {
  651. emptySet := &opencost.AllocationSet{
  652. Allocations: map[string]*opencost.Allocation{},
  653. }
  654. result := transformAllocationSet(emptySet)
  655. require.NotNil(t, result)
  656. assert.Contains(t, result.Allocations, "allocations")
  657. assert.Len(t, result.Allocations["allocations"].Allocations, 0)
  658. }
  659. func TestTransformAssetSet_NilInput(t *testing.T) {
  660. result := transformAssetSet(nil)
  661. require.NotNil(t, result)
  662. assert.NotNil(t, result.Assets)
  663. assert.Len(t, result.Assets, 0)
  664. }
  665. func TestTransformAssetSet_EmptyInput(t *testing.T) {
  666. emptySet := &opencost.AssetSet{
  667. Assets: map[string]opencost.Asset{},
  668. }
  669. result := transformAssetSet(emptySet)
  670. require.NotNil(t, result)
  671. assert.Contains(t, result.Assets, "assets")
  672. assert.Len(t, result.Assets["assets"].Assets, 0)
  673. }
  674. func TestBuildFilterFromParams_EmptyParams(t *testing.T) {
  675. s := &MCPServer{}
  676. params := &CloudCostQuery{}
  677. filter := s.buildFilterFromParams(params)
  678. assert.Nil(t, filter)
  679. }
  680. func TestBuildFilterFromParams_RegionIgnored(t *testing.T) {
  681. s := &MCPServer{}
  682. params := &CloudCostQuery{
  683. Region: "us-east-1", // Should be ignored
  684. }
  685. filter := s.buildFilterFromParams(params)
  686. assert.Nil(t, filter) // Should return nil since only region is set
  687. }
  688. func TestBuildFilterFromParams_EmptyLabelKey(t *testing.T) {
  689. s := &MCPServer{}
  690. params := &CloudCostQuery{
  691. Labels: map[string]string{
  692. "": "value1", // Empty key should be ignored
  693. "valid": "value2",
  694. },
  695. }
  696. filter := s.buildFilterFromParams(params)
  697. assert.NotNil(t, filter)
  698. }
  699. func TestBuildCloudCostQueryRequest_EmptyParams(t *testing.T) {
  700. s := &MCPServer{}
  701. req := cloudcost.QueryRequest{}
  702. params := &CloudCostQuery{}
  703. result := s.buildCloudCostQueryRequest(req, params)
  704. assert.Equal(t, req, result) // Should return unchanged request
  705. }
  706. func TestBuildCloudCostQueryRequest_InvalidFilterString(t *testing.T) {
  707. s := &MCPServer{}
  708. req := cloudcost.QueryRequest{}
  709. params := &CloudCostQuery{
  710. Filter: "invalid filter syntax !!!",
  711. }
  712. result := s.buildCloudCostQueryRequest(req, params)
  713. // Should not panic and should return request with nil filter
  714. assert.Nil(t, result.Filter)
  715. }
  716. func TestQueryCloudCosts_NilCloudQuerier(t *testing.T) {
  717. s := &MCPServer{cloudQuerier: nil}
  718. req := &OpenCostQueryRequest{
  719. QueryType: CloudCostQueryType,
  720. Window: "24h",
  721. }
  722. _, err := s.QueryCloudCosts(context.Background(), req)
  723. require.Error(t, err)
  724. assert.Contains(t, err.Error(), "cloud cost querier not configured")
  725. }
  726. func TestQueryCloudCosts_InvalidWindow(t *testing.T) {
  727. s := &MCPServer{cloudQuerier: &dummyQuerier{}}
  728. req := &OpenCostQueryRequest{
  729. QueryType: CloudCostQueryType,
  730. Window: "invalid-window",
  731. }
  732. _, err := s.QueryCloudCosts(context.Background(), req)
  733. require.Error(t, err)
  734. assert.Contains(t, err.Error(), "failed to parse window")
  735. }
  736. func TestQueryAssets_InvalidWindow(t *testing.T) {
  737. s := &MCPServer{}
  738. req := &OpenCostQueryRequest{
  739. QueryType: AssetQueryType,
  740. Window: "invalid-window",
  741. }
  742. _, err := s.QueryAssets(req)
  743. require.Error(t, err)
  744. assert.Contains(t, err.Error(), "failed to parse window")
  745. }
  746. func TestQueryAllocations_InvalidWindow(t *testing.T) {
  747. s := &MCPServer{}
  748. req := &OpenCostQueryRequest{
  749. QueryType: AllocationQueryType,
  750. Window: "invalid-window",
  751. }
  752. _, err := s.QueryAllocations(req)
  753. require.Error(t, err)
  754. assert.Contains(t, err.Error(), "failed to parse window")
  755. }
  756. func TestProcessMCPRequest_ResponseMetadata(t *testing.T) {
  757. dq := &dummyQuerier{}
  758. s := &MCPServer{cloudQuerier: dq}
  759. req := &MCPRequest{
  760. Query: &OpenCostQueryRequest{
  761. QueryType: CloudCostQueryType,
  762. Window: "1h",
  763. },
  764. }
  765. resp, err := s.ProcessMCPRequest(context.Background(), req)
  766. require.NoError(t, err)
  767. require.NotNil(t, resp)
  768. // Check response metadata
  769. assert.NotEmpty(t, resp.QueryInfo.QueryID)
  770. assert.NotZero(t, resp.QueryInfo.Timestamp)
  771. assert.Greater(t, resp.QueryInfo.ProcessingTime, time.Duration(0))
  772. }
  773. func TestCloudCostQuery_NewFields(t *testing.T) {
  774. query := CloudCostQuery{
  775. InvoiceEntityID: "entity-123",
  776. ProviderID: "provider-456",
  777. Labels: map[string]string{
  778. "environment": "prod",
  779. "team": "platform",
  780. },
  781. }
  782. assert.Equal(t, "entity-123", query.InvoiceEntityID)
  783. assert.Equal(t, "provider-456", query.ProviderID)
  784. assert.Equal(t, "prod", query.Labels["environment"])
  785. assert.Equal(t, "platform", query.Labels["team"])
  786. }
  787. // ---- Tests for Efficiency Tool ----
  788. func TestEfficiencyQueryStruct(t *testing.T) {
  789. bufferMultiplier := 1.4
  790. query := EfficiencyQuery{
  791. Aggregate: "pod",
  792. Filter: "namespace:production",
  793. EfficiencyBufferMultiplier: &bufferMultiplier,
  794. }
  795. assert.Equal(t, "pod", query.Aggregate)
  796. assert.Equal(t, "namespace:production", query.Filter)
  797. assert.NotNil(t, query.EfficiencyBufferMultiplier)
  798. assert.Equal(t, 1.4, *query.EfficiencyBufferMultiplier)
  799. }
  800. func TestEfficiencyQueryDefaultValues(t *testing.T) {
  801. query := EfficiencyQuery{}
  802. assert.Empty(t, query.Aggregate)
  803. assert.Empty(t, query.Filter)
  804. assert.Nil(t, query.EfficiencyBufferMultiplier)
  805. }
  806. func TestEfficiencyMetricStruct(t *testing.T) {
  807. now := time.Now()
  808. metric := EfficiencyMetric{
  809. Name: "test-pod",
  810. CPUEfficiency: 0.5,
  811. MemoryEfficiency: 0.6,
  812. CPUCoresRequested: 2.0,
  813. CPUCoresUsed: 1.0,
  814. RAMBytesRequested: 2147483648, // 2GB
  815. RAMBytesUsed: 1288490188, // ~1.2GB
  816. RecommendedCPURequest: 1.2,
  817. RecommendedRAMRequest: 1546188226, // ~1.44GB
  818. ResultingCPUEfficiency: 0.833,
  819. ResultingMemoryEfficiency: 0.833,
  820. CurrentTotalCost: 10.0,
  821. RecommendedCost: 6.0,
  822. CostSavings: 4.0,
  823. CostSavingsPercent: 40.0,
  824. EfficiencyBufferMultiplier: 1.2,
  825. Start: now.Add(-24 * time.Hour),
  826. End: now,
  827. }
  828. assert.Equal(t, "test-pod", metric.Name)
  829. assert.Equal(t, 0.5, metric.CPUEfficiency)
  830. assert.Equal(t, 0.6, metric.MemoryEfficiency)
  831. assert.Equal(t, 2.0, metric.CPUCoresRequested)
  832. assert.Equal(t, 1.0, metric.CPUCoresUsed)
  833. assert.Equal(t, 2147483648.0, metric.RAMBytesRequested)
  834. assert.Equal(t, 1288490188.0, metric.RAMBytesUsed)
  835. assert.Equal(t, 1.2, metric.RecommendedCPURequest)
  836. assert.Equal(t, 1546188226.0, metric.RecommendedRAMRequest)
  837. assert.Equal(t, 0.833, metric.ResultingCPUEfficiency)
  838. assert.Equal(t, 0.833, metric.ResultingMemoryEfficiency)
  839. assert.Equal(t, 10.0, metric.CurrentTotalCost)
  840. assert.Equal(t, 6.0, metric.RecommendedCost)
  841. assert.Equal(t, 4.0, metric.CostSavings)
  842. assert.Equal(t, 40.0, metric.CostSavingsPercent)
  843. assert.Equal(t, 1.2, metric.EfficiencyBufferMultiplier)
  844. assert.True(t, metric.Start.Before(metric.End))
  845. }
  846. func TestEfficiencyResponseStruct(t *testing.T) {
  847. now := time.Now()
  848. metric1 := &EfficiencyMetric{
  849. Name: "pod-1",
  850. CPUEfficiency: 0.5,
  851. MemoryEfficiency: 0.6,
  852. Start: now.Add(-24 * time.Hour),
  853. End: now,
  854. }
  855. metric2 := &EfficiencyMetric{
  856. Name: "pod-2",
  857. CPUEfficiency: 0.7,
  858. MemoryEfficiency: 0.8,
  859. Start: now.Add(-24 * time.Hour),
  860. End: now,
  861. }
  862. response := EfficiencyResponse{
  863. Efficiencies: []*EfficiencyMetric{metric1, metric2},
  864. }
  865. require.NotNil(t, response.Efficiencies)
  866. assert.Len(t, response.Efficiencies, 2)
  867. assert.Equal(t, "pod-1", response.Efficiencies[0].Name)
  868. assert.Equal(t, "pod-2", response.Efficiencies[1].Name)
  869. }
  870. func TestSafeDiv(t *testing.T) {
  871. tests := []struct {
  872. name string
  873. numerator float64
  874. denominator float64
  875. expected float64
  876. }{
  877. {"normal division", 10.0, 2.0, 5.0},
  878. {"zero denominator", 10.0, 0.0, 0.0},
  879. {"zero numerator", 0.0, 2.0, 0.0},
  880. {"both zero", 0.0, 0.0, 0.0},
  881. {"negative values", -10.0, 2.0, -5.0},
  882. {"fractional result", 5.0, 2.0, 2.5},
  883. }
  884. for _, tt := range tests {
  885. t.Run(tt.name, func(t *testing.T) {
  886. result := safeDiv(tt.numerator, tt.denominator)
  887. assert.Equal(t, tt.expected, result)
  888. })
  889. }
  890. }
  891. func TestComputeEfficiencyMetric_NilAllocation(t *testing.T) {
  892. result := computeEfficiencyMetric(nil, 1.2)
  893. assert.Nil(t, result)
  894. }
  895. func TestComputeEfficiencyMetric_ZeroMinutes(t *testing.T) {
  896. now := time.Now()
  897. alloc := &opencost.Allocation{
  898. Name: "test-pod",
  899. Start: now,
  900. End: now, // Same time, so 0 minutes
  901. }
  902. result := computeEfficiencyMetric(alloc, 1.2)
  903. assert.Nil(t, result)
  904. }
  905. func TestComputeEfficiencyMetric_ValidAllocation(t *testing.T) {
  906. now := time.Now()
  907. alloc := &opencost.Allocation{
  908. Name: "test-pod",
  909. Start: now.Add(-24 * time.Hour),
  910. End: now,
  911. // 24 hours = 1440 minutes
  912. CPUCoreHours: 24.0, // 1 core for 24 hours
  913. RAMByteHours: 24.0e9, // ~1GB for 24 hours
  914. CPUCoreRequestAverage: 2.0, // Requested 2 cores
  915. RAMBytesRequestAverage: 2.0e9, // Requested 2GB
  916. CPUCost: 10.0,
  917. RAMCost: 5.0,
  918. }
  919. result := computeEfficiencyMetric(alloc, 1.2)
  920. require.NotNil(t, result)
  921. assert.Equal(t, "test-pod", result.Name)
  922. assert.Equal(t, 2.0, result.CPUCoresRequested)
  923. assert.Equal(t, 2.0e9, result.RAMBytesRequested)
  924. assert.Equal(t, 1.0, result.CPUCoresUsed) // 24 core-hours / 24 hours = 1 core
  925. assert.Equal(t, 1.0e9, result.RAMBytesUsed) // 24GB-hours / 24 hours = 1GB
  926. assert.Equal(t, 0.5, result.CPUEfficiency) // 1 / 2 = 0.5
  927. assert.Equal(t, 0.5, result.MemoryEfficiency) // 1GB / 2GB = 0.5
  928. assert.Equal(t, 1.2, result.RecommendedCPURequest) // 1 * 1.2 = 1.2
  929. assert.Equal(t, 1.2e9, result.RecommendedRAMRequest) // 1GB * 1.2 = 1.2GB
  930. assert.Equal(t, 1.2, result.EfficiencyBufferMultiplier)
  931. assert.Greater(t, result.CostSavings, 0.0)
  932. }
  933. func TestComputeEfficiencyMetric_CustomBufferMultiplier(t *testing.T) {
  934. now := time.Now()
  935. alloc := &opencost.Allocation{
  936. Name: "test-pod",
  937. Start: now.Add(-24 * time.Hour),
  938. End: now,
  939. CPUCoreHours: 24.0,
  940. RAMByteHours: 24.0e9,
  941. CPUCoreRequestAverage: 2.0,
  942. RAMBytesRequestAverage: 2.0e9,
  943. CPUCost: 10.0,
  944. RAMCost: 5.0,
  945. }
  946. // Test with 1.4 buffer multiplier (40% headroom)
  947. result := computeEfficiencyMetric(alloc, 1.4)
  948. require.NotNil(t, result)
  949. assert.Equal(t, 1.4, result.RecommendedCPURequest) // 1 * 1.4 = 1.4
  950. assert.Equal(t, 1.4e9, result.RecommendedRAMRequest) // 1GB * 1.4 = 1.4GB
  951. assert.Equal(t, 1.4, result.EfficiencyBufferMultiplier)
  952. // Resulting efficiency should be usage / recommended
  953. expectedCPUEff := 1.0 / 1.4
  954. expectedMemEff := 1.0e9 / 1.4e9
  955. assert.InDelta(t, expectedCPUEff, result.ResultingCPUEfficiency, 0.001)
  956. assert.InDelta(t, expectedMemEff, result.ResultingMemoryEfficiency, 0.001)
  957. }
  958. func TestComputeEfficiencyMetric_MinimumThresholds(t *testing.T) {
  959. now := time.Now()
  960. alloc := &opencost.Allocation{
  961. Name: "test-pod",
  962. Start: now.Add(-24 * time.Hour),
  963. End: now,
  964. // Very small usage
  965. CPUCoreHours: 0.00001, // 0.000000417 cores average
  966. RAMByteHours: 100, // ~4 bytes average
  967. CPUCoreRequestAverage: 0.1,
  968. RAMBytesRequestAverage: 1000,
  969. CPUCost: 0.001,
  970. RAMCost: 0.001,
  971. }
  972. result := computeEfficiencyMetric(alloc, 1.2)
  973. require.NotNil(t, result)
  974. // Should enforce minimum CPU (0.001 cores)
  975. assert.Equal(t, efficiencyMinCPU, result.RecommendedCPURequest)
  976. // Should enforce minimum RAM (1MB)
  977. assert.Equal(t, float64(efficiencyMinRAM), result.RecommendedRAMRequest)
  978. }
  979. func TestComputeEfficiencyMetric_NoRequests(t *testing.T) {
  980. now := time.Now()
  981. alloc := &opencost.Allocation{
  982. Name: "test-pod",
  983. Start: now.Add(-24 * time.Hour),
  984. End: now,
  985. CPUCoreHours: 24.0,
  986. RAMByteHours: 24.0e9,
  987. CPUCoreRequestAverage: 0.0, // No requests set
  988. RAMBytesRequestAverage: 0.0, // No requests set
  989. CPUCost: 10.0,
  990. RAMCost: 5.0,
  991. }
  992. result := computeEfficiencyMetric(alloc, 1.2)
  993. require.NotNil(t, result)
  994. // Efficiency should be 0 when no requests are set
  995. assert.Equal(t, 0.0, result.CPUEfficiency)
  996. assert.Equal(t, 0.0, result.MemoryEfficiency)
  997. // Recommendations should still be calculated based on usage
  998. assert.Equal(t, 1.2, result.RecommendedCPURequest)
  999. assert.Equal(t, 1.2e9, result.RecommendedRAMRequest)
  1000. }
  1001. func TestComputeEfficiencyMetric_OverProvisioned(t *testing.T) {
  1002. now := time.Now()
  1003. alloc := &opencost.Allocation{
  1004. Name: "test-pod",
  1005. Start: now.Add(-24 * time.Hour),
  1006. End: now,
  1007. CPUCoreHours: 12.0, // 0.5 cores average
  1008. RAMByteHours: 12.0e9, // 0.5GB average
  1009. CPUCoreRequestAverage: 4.0, // Requested 4 cores (over-provisioned)
  1010. RAMBytesRequestAverage: 8.0e9, // Requested 8GB (over-provisioned)
  1011. CPUCost: 40.0,
  1012. RAMCost: 20.0,
  1013. }
  1014. result := computeEfficiencyMetric(alloc, 1.2)
  1015. require.NotNil(t, result)
  1016. // Low efficiency due to over-provisioning
  1017. assert.Equal(t, 0.125, result.CPUEfficiency) // 0.5 / 4 = 0.125
  1018. assert.Equal(t, 0.0625, result.MemoryEfficiency) // 0.5GB / 8GB = 0.0625
  1019. // Recommendations should be much lower
  1020. assert.Equal(t, 0.6, result.RecommendedCPURequest) // 0.5 * 1.2 = 0.6
  1021. assert.Equal(t, 0.6e9, result.RecommendedRAMRequest) // 0.5GB * 1.2 = 0.6GB
  1022. // Should have significant cost savings
  1023. assert.Greater(t, result.CostSavings, 0.0)
  1024. assert.Greater(t, result.CostSavingsPercent, 50.0)
  1025. }
  1026. func TestComputeEfficiencyMetric_UnderProvisioned(t *testing.T) {
  1027. now := time.Now()
  1028. alloc := &opencost.Allocation{
  1029. Name: "test-pod",
  1030. Start: now.Add(-24 * time.Hour),
  1031. End: now,
  1032. CPUCoreHours: 48.0, // 2 cores average
  1033. RAMByteHours: 48.0e9, // 2GB average
  1034. CPUCoreRequestAverage: 1.0, // Requested 1 core (under-provisioned)
  1035. RAMBytesRequestAverage: 1.0e9, // Requested 1GB (under-provisioned)
  1036. CPUCost: 10.0,
  1037. RAMCost: 5.0,
  1038. }
  1039. result := computeEfficiencyMetric(alloc, 1.2)
  1040. require.NotNil(t, result)
  1041. // High efficiency (>100%) due to under-provisioning
  1042. assert.Equal(t, 2.0, result.CPUEfficiency) // 2 / 1 = 2.0
  1043. assert.Equal(t, 2.0, result.MemoryEfficiency) // 2GB / 1GB = 2.0
  1044. // Recommendations should be higher than current requests
  1045. assert.Equal(t, 2.4, result.RecommendedCPURequest) // 2 * 1.2 = 2.4
  1046. assert.Equal(t, 2.4e9, result.RecommendedRAMRequest) // 2GB * 1.2 = 2.4GB
  1047. }
  1048. func TestComputeEfficiencyMetric_CostCalculations(t *testing.T) {
  1049. now := time.Now()
  1050. alloc := &opencost.Allocation{
  1051. Name: "test-pod",
  1052. Start: now.Add(-24 * time.Hour),
  1053. End: now,
  1054. CPUCoreHours: 24.0,
  1055. RAMByteHours: 24.0e9,
  1056. CPUCoreRequestAverage: 2.0,
  1057. RAMBytesRequestAverage: 2.0e9,
  1058. CPUCost: 10.0, // $10 for CPU
  1059. RAMCost: 5.0, // $5 for RAM
  1060. NetworkCost: 1.0, // $1 for network
  1061. SharedCost: 0.5, // $0.5 shared
  1062. ExternalCost: 0.5, // $0.5 external
  1063. GPUCost: 1.0, // $1 for GPU
  1064. }
  1065. result := computeEfficiencyMetric(alloc, 1.2)
  1066. require.NotNil(t, result)
  1067. // Current total cost should include all costs
  1068. expectedCurrentCost := 10.0 + 5.0 + 1.0 + 0.5 + 0.5 + 1.0 // = 18.0
  1069. assert.Equal(t, expectedCurrentCost, result.CurrentTotalCost)
  1070. // Recommended cost should be lower due to right-sizing
  1071. assert.Less(t, result.RecommendedCost, result.CurrentTotalCost)
  1072. // Cost savings should be positive
  1073. assert.Greater(t, result.CostSavings, 0.0)
  1074. assert.Equal(t, result.CurrentTotalCost-result.RecommendedCost, result.CostSavings)
  1075. // Cost savings percent should be calculated correctly
  1076. expectedPercent := (result.CostSavings / result.CurrentTotalCost) * 100
  1077. assert.InDelta(t, expectedPercent, result.CostSavingsPercent, 0.001)
  1078. }
  1079. func TestComputeEfficiencyMetric_OtherCostsPreserved(t *testing.T) {
  1080. now := time.Now()
  1081. alloc := &opencost.Allocation{
  1082. Name: "test-pod",
  1083. Start: now.Add(-24 * time.Hour),
  1084. End: now,
  1085. CPUCoreHours: 24.0,
  1086. RAMByteHours: 24.0e9,
  1087. CPUCoreRequestAverage: 2.0,
  1088. RAMBytesRequestAverage: 2.0e9,
  1089. CPUCost: 10.0,
  1090. RAMCost: 5.0,
  1091. NetworkCost: 2.0, // Fixed cost
  1092. SharedCost: 1.0, // Fixed cost
  1093. ExternalCost: 1.0, // Fixed cost
  1094. GPUCost: 0.0,
  1095. }
  1096. result := computeEfficiencyMetric(alloc, 1.2)
  1097. require.NotNil(t, result)
  1098. // The "other costs" (Network, Shared, External, GPU) should be preserved
  1099. // in the recommended cost calculation
  1100. otherCosts := 2.0 + 1.0 + 1.0 + 0.0 // = 4.0
  1101. // CPU and RAM costs should be reduced based on right-sizing
  1102. // Original: 10.0 + 5.0 = 15.0
  1103. // Usage: 1 core + 1GB
  1104. // Recommended: 1.2 cores + 1.2GB
  1105. // Cost is calculated based on REQUESTED amounts (2 cores, 2GB)
  1106. cpuCostPerCoreHour := 10.0 / (2.0 * 24.0) // CPU cost / (requested cores * hours)
  1107. ramCostPerByteHour := 5.0 / (2.0e9 * 24.0) // RAM cost / (requested bytes * hours)
  1108. expectedRecommendedCPUCost := 1.2 * 24.0 * cpuCostPerCoreHour
  1109. expectedRecommendedRAMCost := 1.2e9 * 24.0 * ramCostPerByteHour
  1110. expectedRecommendedTotal := expectedRecommendedCPUCost + expectedRecommendedRAMCost + otherCosts
  1111. assert.InDelta(t, expectedRecommendedTotal, result.RecommendedCost, 0.01)
  1112. }
  1113. func TestQueryEfficiency_InvalidWindow(t *testing.T) {
  1114. s := &MCPServer{}
  1115. req := &OpenCostQueryRequest{
  1116. QueryType: EfficiencyQueryType,
  1117. Window: "invalid-window",
  1118. }
  1119. _, err := s.QueryEfficiency(req)
  1120. require.Error(t, err)
  1121. assert.Contains(t, err.Error(), "failed to parse window")
  1122. }
  1123. func TestQueryEfficiency_DefaultBufferMultiplier(t *testing.T) {
  1124. // Test that default buffer multiplier is 1.2 when not specified
  1125. req := &OpenCostQueryRequest{
  1126. QueryType: EfficiencyQueryType,
  1127. Window: "24h",
  1128. EfficiencyParams: &EfficiencyQuery{
  1129. // EfficiencyBufferMultiplier not set - should default to 1.2
  1130. },
  1131. }
  1132. assert.Nil(t, req.EfficiencyParams.EfficiencyBufferMultiplier)
  1133. }
  1134. func TestQueryEfficiency_CustomBufferMultiplier(t *testing.T) {
  1135. bufferMultiplier := 1.4
  1136. req := &OpenCostQueryRequest{
  1137. QueryType: EfficiencyQueryType,
  1138. Window: "24h",
  1139. EfficiencyParams: &EfficiencyQuery{
  1140. EfficiencyBufferMultiplier: &bufferMultiplier,
  1141. },
  1142. }
  1143. assert.NotNil(t, req.EfficiencyParams.EfficiencyBufferMultiplier)
  1144. assert.Equal(t, 1.4, *req.EfficiencyParams.EfficiencyBufferMultiplier)
  1145. }
  1146. func TestQueryEfficiency_WithFilter(t *testing.T) {
  1147. req := &OpenCostQueryRequest{
  1148. QueryType: EfficiencyQueryType,
  1149. Window: "7d",
  1150. EfficiencyParams: &EfficiencyQuery{
  1151. Aggregate: "pod",
  1152. Filter: "namespace:production",
  1153. },
  1154. }
  1155. assert.Equal(t, "pod", req.EfficiencyParams.Aggregate)
  1156. assert.Equal(t, "namespace:production", req.EfficiencyParams.Filter)
  1157. }
  1158. func TestQueryEfficiency_WithAggregation(t *testing.T) {
  1159. req := &OpenCostQueryRequest{
  1160. QueryType: EfficiencyQueryType,
  1161. Window: "7d",
  1162. EfficiencyParams: &EfficiencyQuery{
  1163. Aggregate: "namespace,controller",
  1164. },
  1165. }
  1166. assert.Equal(t, "namespace,controller", req.EfficiencyParams.Aggregate)
  1167. }
  1168. func TestEfficiencyConstants(t *testing.T) {
  1169. // Test that efficiency constants are defined correctly
  1170. assert.Equal(t, 1.2, efficiencyBufferMultiplier)
  1171. assert.Equal(t, 0.001, efficiencyMinCPU)
  1172. assert.Equal(t, 1024*1024, efficiencyMinRAM)
  1173. }
  1174. func TestEfficiencyQueryType(t *testing.T) {
  1175. assert.Equal(t, QueryType("efficiency"), EfficiencyQueryType)
  1176. }
  1177. // TestTransformCloudCostSetRange_NilPointerHandling verifies that nil pointer dereferences
  1178. // are prevented in transformCloudCostSetRange for issue #3502
  1179. func TestTransformCloudCostSetRange_NilPointerHandling(t *testing.T) {
  1180. now := time.Now().UTC()
  1181. start := now.Add(-24 * time.Hour)
  1182. end := now
  1183. tests := []struct {
  1184. name string
  1185. ccsr *opencost.CloudCostSetRange
  1186. expectedCostCount int
  1187. expectEmpty bool
  1188. }{
  1189. {
  1190. name: "nil CloudCostSetRange",
  1191. ccsr: nil,
  1192. expectEmpty: true,
  1193. },
  1194. {
  1195. name: "nil CloudCostSet in slice",
  1196. ccsr: &opencost.CloudCostSetRange{CloudCostSets: []*opencost.CloudCostSet{nil}},
  1197. expectEmpty: true,
  1198. },
  1199. {
  1200. name: "CloudCostSet with nil Window.Start",
  1201. ccsr: &opencost.CloudCostSetRange{
  1202. CloudCostSets: []*opencost.CloudCostSet{
  1203. {CloudCosts: map[string]*opencost.CloudCost{}, Window: opencost.NewWindow(nil, &end)},
  1204. },
  1205. },
  1206. expectEmpty: true,
  1207. },
  1208. {
  1209. name: "CloudCostSet with nil Window.End",
  1210. ccsr: &opencost.CloudCostSetRange{
  1211. CloudCostSets: []*opencost.CloudCostSet{
  1212. {CloudCosts: map[string]*opencost.CloudCost{}, Window: opencost.NewWindow(&start, nil)},
  1213. },
  1214. },
  1215. expectEmpty: true,
  1216. },
  1217. {
  1218. name: "CloudCost item with nil Window.Start",
  1219. ccsr: &opencost.CloudCostSetRange{
  1220. CloudCostSets: []*opencost.CloudCostSet{
  1221. {
  1222. CloudCosts: map[string]*opencost.CloudCost{
  1223. "cost1": {
  1224. Properties: &opencost.CloudCostProperties{Provider: "aws"},
  1225. Window: opencost.NewWindow(nil, &end),
  1226. NetCost: opencost.CostMetric{Cost: 100.0},
  1227. },
  1228. },
  1229. Window: opencost.NewClosedWindow(start, end),
  1230. },
  1231. },
  1232. },
  1233. expectedCostCount: 0,
  1234. },
  1235. {
  1236. name: "Mixed valid and invalid items",
  1237. ccsr: &opencost.CloudCostSetRange{
  1238. CloudCostSets: []*opencost.CloudCostSet{
  1239. {
  1240. CloudCosts: map[string]*opencost.CloudCost{
  1241. "invalid": nil,
  1242. "valid": {
  1243. Properties: &opencost.CloudCostProperties{Provider: "gcp"},
  1244. Window: opencost.NewClosedWindow(start, end),
  1245. NetCost: opencost.CostMetric{Cost: 200.0, KubernetesPercent: 0.5},
  1246. },
  1247. },
  1248. Window: opencost.NewClosedWindow(start, end),
  1249. },
  1250. },
  1251. },
  1252. expectedCostCount: 1,
  1253. },
  1254. }
  1255. for _, tt := range tests {
  1256. t.Run(tt.name, func(t *testing.T) {
  1257. // Should not panic
  1258. result := transformCloudCostSetRange(tt.ccsr)
  1259. require.NotNil(t, result)
  1260. require.NotNil(t, result.CloudCosts)
  1261. require.NotNil(t, result.Summary)
  1262. if tt.expectEmpty {
  1263. assert.Empty(t, result.CloudCosts)
  1264. assert.Equal(t, 0.0, result.Summary.TotalNetCost)
  1265. } else {
  1266. totalCosts := 0
  1267. for _, set := range result.CloudCosts {
  1268. totalCosts += len(set.CloudCosts)
  1269. }
  1270. assert.Equal(t, tt.expectedCostCount, totalCosts)
  1271. }
  1272. })
  1273. }
  1274. }
  1275. // contextAwareQuerier is a mock querier that checks for context cancellation
  1276. type contextAwareQuerier struct {
  1277. contextWasCancelled bool
  1278. }
  1279. func (caq *contextAwareQuerier) Query(ctx context.Context, req cloudcost.QueryRequest) (*opencost.CloudCostSetRange, error) {
  1280. // Check if context is already cancelled
  1281. select {
  1282. case <-ctx.Done():
  1283. caq.contextWasCancelled = true
  1284. return nil, ctx.Err()
  1285. default:
  1286. // Return empty set range
  1287. ccsr, _ := opencost.NewCloudCostSetRange(time.Now().Add(-24*time.Hour), time.Now(), opencost.AccumulateOptionDay, "")
  1288. return ccsr, nil
  1289. }
  1290. }
  1291. func (caq *contextAwareQuerier) QueryCloudCostAutocomplete(ctx context.Context, _ autocomplete.Request) (*autocomplete.Response, error) {
  1292. select {
  1293. case <-ctx.Done():
  1294. caq.contextWasCancelled = true
  1295. return nil, ctx.Err()
  1296. default:
  1297. return &autocomplete.Response{Data: []string{}}, nil
  1298. }
  1299. }
  1300. func TestQueryCloudCosts_ContextCancellation(t *testing.T) {
  1301. // Create a context that is already cancelled
  1302. ctx, cancel := context.WithCancel(context.Background())
  1303. cancel() // Cancel immediately
  1304. // Create a context-aware mock querier
  1305. caq := &contextAwareQuerier{}
  1306. s := &MCPServer{cloudQuerier: caq}
  1307. req := &OpenCostQueryRequest{
  1308. QueryType: CloudCostQueryType,
  1309. Window: "1d",
  1310. }
  1311. // Query should fail with context cancelled error
  1312. _, err := s.QueryCloudCosts(ctx, req)
  1313. // Verify context cancellation was detected
  1314. assert.Error(t, err)
  1315. assert.True(t, caq.contextWasCancelled, "Context cancellation should be detected by querier")
  1316. assert.ErrorIs(t, err, context.Canceled, "Error should be context.Canceled")
  1317. }
  1318. func TestProcessMCPRequest_ContextPropagation(t *testing.T) {
  1319. // Test that context is properly propagated through ProcessMCPRequest
  1320. ctx := context.Background()
  1321. dq := &dummyQuerier{}
  1322. s := &MCPServer{cloudQuerier: dq}
  1323. req := &MCPRequest{
  1324. Query: &OpenCostQueryRequest{
  1325. QueryType: CloudCostQueryType,
  1326. Window: "1d",
  1327. },
  1328. }
  1329. resp, err := s.ProcessMCPRequest(ctx, req)
  1330. require.NoError(t, err)
  1331. require.NotNil(t, resp)
  1332. // Verify that the querier was called (context was propagated)
  1333. assert.NotNil(t, dq.last)
  1334. }