server_test.go 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525
  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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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. // TestTransformCloudCostSetRange_NilPointerHandling verifies that nil pointer dereferences
  1174. // are prevented in transformCloudCostSetRange for issue #3502
  1175. func TestTransformCloudCostSetRange_NilPointerHandling(t *testing.T) {
  1176. now := time.Now().UTC()
  1177. start := now.Add(-24 * time.Hour)
  1178. end := now
  1179. tests := []struct {
  1180. name string
  1181. ccsr *opencost.CloudCostSetRange
  1182. expectedCostCount int
  1183. expectEmpty bool
  1184. }{
  1185. {
  1186. name: "nil CloudCostSetRange",
  1187. ccsr: nil,
  1188. expectEmpty: true,
  1189. },
  1190. {
  1191. name: "nil CloudCostSet in slice",
  1192. ccsr: &opencost.CloudCostSetRange{CloudCostSets: []*opencost.CloudCostSet{nil}},
  1193. expectEmpty: true,
  1194. },
  1195. {
  1196. name: "CloudCostSet with nil Window.Start",
  1197. ccsr: &opencost.CloudCostSetRange{
  1198. CloudCostSets: []*opencost.CloudCostSet{
  1199. {CloudCosts: map[string]*opencost.CloudCost{}, Window: opencost.NewWindow(nil, &end)},
  1200. },
  1201. },
  1202. expectEmpty: true,
  1203. },
  1204. {
  1205. name: "CloudCostSet with nil Window.End",
  1206. ccsr: &opencost.CloudCostSetRange{
  1207. CloudCostSets: []*opencost.CloudCostSet{
  1208. {CloudCosts: map[string]*opencost.CloudCost{}, Window: opencost.NewWindow(&start, nil)},
  1209. },
  1210. },
  1211. expectEmpty: true,
  1212. },
  1213. {
  1214. name: "CloudCost item with nil Window.Start",
  1215. ccsr: &opencost.CloudCostSetRange{
  1216. CloudCostSets: []*opencost.CloudCostSet{
  1217. {
  1218. CloudCosts: map[string]*opencost.CloudCost{
  1219. "cost1": {
  1220. Properties: &opencost.CloudCostProperties{Provider: "aws"},
  1221. Window: opencost.NewWindow(nil, &end),
  1222. NetCost: opencost.CostMetric{Cost: 100.0},
  1223. },
  1224. },
  1225. Window: opencost.NewClosedWindow(start, end),
  1226. },
  1227. },
  1228. },
  1229. expectedCostCount: 0,
  1230. },
  1231. {
  1232. name: "Mixed valid and invalid items",
  1233. ccsr: &opencost.CloudCostSetRange{
  1234. CloudCostSets: []*opencost.CloudCostSet{
  1235. {
  1236. CloudCosts: map[string]*opencost.CloudCost{
  1237. "invalid": nil,
  1238. "valid": {
  1239. Properties: &opencost.CloudCostProperties{Provider: "gcp"},
  1240. Window: opencost.NewClosedWindow(start, end),
  1241. NetCost: opencost.CostMetric{Cost: 200.0, KubernetesPercent: 0.5},
  1242. },
  1243. },
  1244. Window: opencost.NewClosedWindow(start, end),
  1245. },
  1246. },
  1247. },
  1248. expectedCostCount: 1,
  1249. },
  1250. }
  1251. for _, tt := range tests {
  1252. t.Run(tt.name, func(t *testing.T) {
  1253. // Should not panic
  1254. result := transformCloudCostSetRange(tt.ccsr)
  1255. require.NotNil(t, result)
  1256. require.NotNil(t, result.CloudCosts)
  1257. require.NotNil(t, result.Summary)
  1258. if tt.expectEmpty {
  1259. assert.Empty(t, result.CloudCosts)
  1260. assert.Equal(t, 0.0, result.Summary.TotalNetCost)
  1261. } else {
  1262. totalCosts := 0
  1263. for _, set := range result.CloudCosts {
  1264. totalCosts += len(set.CloudCosts)
  1265. }
  1266. assert.Equal(t, tt.expectedCostCount, totalCosts)
  1267. }
  1268. })
  1269. }
  1270. }
  1271. // contextAwareQuerier is a mock querier that checks for context cancellation
  1272. type contextAwareQuerier struct {
  1273. contextWasCancelled bool
  1274. }
  1275. func (caq *contextAwareQuerier) Query(ctx context.Context, req cloudcost.QueryRequest) (*opencost.CloudCostSetRange, error) {
  1276. // Check if context is already cancelled
  1277. select {
  1278. case <-ctx.Done():
  1279. caq.contextWasCancelled = true
  1280. return nil, ctx.Err()
  1281. default:
  1282. // Return empty set range
  1283. ccsr, _ := opencost.NewCloudCostSetRange(time.Now().Add(-24*time.Hour), time.Now(), opencost.AccumulateOptionDay, "")
  1284. return ccsr, nil
  1285. }
  1286. }
  1287. func TestQueryCloudCosts_ContextCancellation(t *testing.T) {
  1288. // Create a context that is already cancelled
  1289. ctx, cancel := context.WithCancel(context.Background())
  1290. cancel() // Cancel immediately
  1291. // Create a context-aware mock querier
  1292. caq := &contextAwareQuerier{}
  1293. s := &MCPServer{cloudQuerier: caq}
  1294. req := &OpenCostQueryRequest{
  1295. QueryType: CloudCostQueryType,
  1296. Window: "1d",
  1297. }
  1298. // Query should fail with context cancelled error
  1299. _, err := s.QueryCloudCosts(ctx, req)
  1300. // Verify context cancellation was detected
  1301. assert.Error(t, err)
  1302. assert.True(t, caq.contextWasCancelled, "Context cancellation should be detected by querier")
  1303. assert.ErrorIs(t, err, context.Canceled, "Error should be context.Canceled")
  1304. }
  1305. func TestProcessMCPRequest_ContextPropagation(t *testing.T) {
  1306. // Test that context is properly propagated through ProcessMCPRequest
  1307. ctx := context.Background()
  1308. dq := &dummyQuerier{}
  1309. s := &MCPServer{cloudQuerier: dq}
  1310. req := &MCPRequest{
  1311. Query: &OpenCostQueryRequest{
  1312. QueryType: CloudCostQueryType,
  1313. Window: "1d",
  1314. },
  1315. }
  1316. resp, err := s.ProcessMCPRequest(ctx, req)
  1317. require.NoError(t, err)
  1318. require.NotNil(t, resp)
  1319. // Verify that the querier was called (context was propagated)
  1320. assert.NotNil(t, dq.last)
  1321. }