server_test.go 49 KB

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