server_test.go 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539
  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 (dq *dummyQuerier) QueryCloudCostAutocomplete(_ context.Context, _ cloudcost.CloudCostAutocompleteRequest) (*cloudcost.CloudCostAutocompleteResponse, error) {
  482. return &cloudcost.CloudCostAutocompleteResponse{Data: []string{}}, nil
  483. }
  484. func TestBuildCloudCostQueryRequest_AccumulateParsing(t *testing.T) {
  485. s := &MCPServer{}
  486. req := cloudcost.QueryRequest{}
  487. params := &CloudCostQuery{
  488. Aggregate: "provider,service",
  489. Accumulate: "week",
  490. }
  491. out := s.buildCloudCostQueryRequest(req, params)
  492. assert.Equal(t, []string{"provider", "service"}, out.AggregateBy)
  493. assert.NotEqual(t, opencost.AccumulateOptionNone, out.Accumulate)
  494. }
  495. func TestBuildCloudCostQueryRequest_FilterString(t *testing.T) {
  496. s := &MCPServer{}
  497. req := cloudcost.QueryRequest{}
  498. params := &CloudCostQuery{
  499. Filter: `provider:"gcp" and service:"Compute Engine"`,
  500. }
  501. out := s.buildCloudCostQueryRequest(req, params)
  502. assert.NotNil(t, out.Filter)
  503. }
  504. func TestBuildFilterFromParams_SupportedFieldsOnly(t *testing.T) {
  505. s := &MCPServer{}
  506. params := &CloudCostQuery{
  507. Provider: "gcp",
  508. ProviderID: "cluster-1",
  509. Service: "Compute Engine",
  510. Category: "compute",
  511. AccountID: "acct-123",
  512. InvoiceEntityID: "inv-456",
  513. Region: "us-central1", // intentionally set; ignored by builder
  514. Labels: map[string]string{
  515. "goog-k8s-cluster-name": "cluster-1",
  516. },
  517. }
  518. f := s.buildFilterFromParams(params)
  519. assert.NotNil(t, f)
  520. }
  521. func TestBuildFilterFromParams_LabelOnly(t *testing.T) {
  522. s := &MCPServer{}
  523. params := &CloudCostQuery{
  524. Labels: map[string]string{"environment": "prod"},
  525. }
  526. f := s.buildFilterFromParams(params)
  527. assert.NotNil(t, f)
  528. }
  529. func TestQueryCloudCosts_QuerierCapture(t *testing.T) {
  530. dq := &dummyQuerier{}
  531. s := &MCPServer{cloudQuerier: dq}
  532. req := &OpenCostQueryRequest{
  533. QueryType: CloudCostQueryType,
  534. Window: "5d",
  535. CloudCostParams: &CloudCostQuery{
  536. Aggregate: "provider,service",
  537. Accumulate: "week",
  538. Provider: "gcp",
  539. },
  540. }
  541. _, err := s.QueryCloudCosts(context.Background(), req)
  542. require.NoError(t, err)
  543. assert.Equal(t, []string{"provider", "service"}, dq.last.AggregateBy)
  544. assert.NotEqual(t, opencost.AccumulateOptionNone, dq.last.Accumulate)
  545. }
  546. // ---- Tests for MCP server end-to-end behavior ----
  547. func TestProcessMCPRequest_CloudCostDispatch(t *testing.T) {
  548. dq := &dummyQuerier{}
  549. s := &MCPServer{cloudQuerier: dq}
  550. req := &MCPRequest{
  551. Query: &OpenCostQueryRequest{
  552. QueryType: CloudCostQueryType,
  553. Window: "3d",
  554. CloudCostParams: &CloudCostQuery{
  555. Aggregate: "provider",
  556. Accumulate: "day",
  557. Provider: "gcp",
  558. },
  559. },
  560. }
  561. resp, err := s.ProcessMCPRequest(context.Background(), req)
  562. require.NoError(t, err)
  563. require.NotNil(t, resp)
  564. require.NotNil(t, resp.Data)
  565. }
  566. func TestProcessMCPRequest_UnsupportedType(t *testing.T) {
  567. s := &MCPServer{}
  568. req := &MCPRequest{
  569. Query: &OpenCostQueryRequest{
  570. QueryType: QueryType("unknown"),
  571. Window: "1d",
  572. },
  573. }
  574. _, err := s.ProcessMCPRequest(context.Background(), req)
  575. require.Error(t, err)
  576. }
  577. func TestProcessMCPRequest_ValidationError(t *testing.T) {
  578. s := &MCPServer{}
  579. // Missing window
  580. req := &MCPRequest{
  581. Query: &OpenCostQueryRequest{
  582. QueryType: CloudCostQueryType,
  583. Window: "",
  584. },
  585. }
  586. _, err := s.ProcessMCPRequest(context.Background(), req)
  587. require.Error(t, err)
  588. }
  589. // ---- Additional comprehensive tests for missing functionality ----
  590. func TestNewMCPServer(t *testing.T) {
  591. costModel := &costmodel.CostModel{}
  592. provider := &mockProvider{}
  593. cloudQuerier := &dummyQuerier{}
  594. server := NewMCPServer(costModel, provider, cloudQuerier)
  595. require.NotNil(t, server)
  596. assert.Equal(t, costModel, server.costModel)
  597. assert.Equal(t, provider, server.provider)
  598. assert.Equal(t, cloudQuerier, server.cloudQuerier)
  599. }
  600. // Mock provider for testing
  601. type mockProvider struct{}
  602. func (mp *mockProvider) GetConfig() (*models.CustomPricing, error) { return nil, nil }
  603. func (mp *mockProvider) AllNodePricing() (interface{}, error) { return nil, nil }
  604. func (mp *mockProvider) ClusterInfo() (map[string]string, error) { return nil, nil }
  605. func (mp *mockProvider) GetAddresses() ([]byte, error) { return nil, nil }
  606. func (mp *mockProvider) GetDisks() ([]byte, error) { return nil, nil }
  607. func (mp *mockProvider) GetOrphanedResources() ([]models.OrphanedResource, error) { return nil, nil }
  608. func (mp *mockProvider) NodePricing(models.Key) (*models.Node, models.PricingMetadata, error) {
  609. return nil, models.PricingMetadata{}, nil
  610. }
  611. func (mp *mockProvider) GpuPricing(map[string]string) (string, error) { return "", nil }
  612. func (mp *mockProvider) PVPricing(models.PVKey) (*models.PV, error) { return nil, nil }
  613. func (mp *mockProvider) NetworkPricing() (*models.Network, error) { return nil, nil }
  614. func (mp *mockProvider) LoadBalancerPricing() (*models.LoadBalancer, error) { return nil, nil }
  615. func (mp *mockProvider) DownloadPricingData() error { return nil }
  616. func (mp *mockProvider) GetKey(map[string]string, *clustercache.Node) models.Key { return nil }
  617. func (mp *mockProvider) GetPVKey(*clustercache.PersistentVolume, map[string]string, string) models.PVKey {
  618. return nil
  619. }
  620. func (mp *mockProvider) UpdateConfig(io.Reader, string) (*models.CustomPricing, error) {
  621. return nil, nil
  622. }
  623. func (mp *mockProvider) UpdateConfigFromConfigMap(map[string]string) (*models.CustomPricing, error) {
  624. return nil, nil
  625. }
  626. func (mp *mockProvider) GetManagementPlatform() (string, error) { return "", nil }
  627. func (mp *mockProvider) ApplyReservedInstancePricing(map[string]*models.Node) {}
  628. func (mp *mockProvider) ServiceAccountStatus() *models.ServiceAccountStatus { return nil }
  629. func (mp *mockProvider) PricingSourceStatus() map[string]*models.PricingSource { return nil }
  630. func (mp *mockProvider) ClusterManagementPricing() (string, float64, error) { return "", 0, nil }
  631. func (mp *mockProvider) CombinedDiscountForNode(string, bool, float64, float64) float64 { return 0 }
  632. func (mp *mockProvider) Regions() []string { return nil }
  633. func (mp *mockProvider) PricingSourceSummary() interface{} { return nil }
  634. func TestGenerateQueryID(t *testing.T) {
  635. // Test that generateQueryID returns a non-empty string
  636. id1 := generateQueryID()
  637. id2 := generateQueryID()
  638. assert.NotEmpty(t, id1)
  639. assert.NotEmpty(t, id2)
  640. assert.NotEqual(t, id1, id2) // Should be different each time
  641. assert.Contains(t, id1, "query-")
  642. }
  643. func TestTransformAllocationSet_NilInput(t *testing.T) {
  644. result := transformAllocationSet(nil)
  645. require.NotNil(t, result)
  646. assert.NotNil(t, result.Allocations)
  647. assert.Len(t, result.Allocations, 0)
  648. }
  649. func TestTransformAllocationSet_EmptyInput(t *testing.T) {
  650. emptySet := &opencost.AllocationSet{
  651. Allocations: map[string]*opencost.Allocation{},
  652. }
  653. result := transformAllocationSet(emptySet)
  654. require.NotNil(t, result)
  655. assert.Contains(t, result.Allocations, "allocations")
  656. assert.Len(t, result.Allocations["allocations"].Allocations, 0)
  657. }
  658. func TestTransformAssetSet_NilInput(t *testing.T) {
  659. result := transformAssetSet(nil)
  660. require.NotNil(t, result)
  661. assert.NotNil(t, result.Assets)
  662. assert.Len(t, result.Assets, 0)
  663. }
  664. func TestTransformAssetSet_EmptyInput(t *testing.T) {
  665. emptySet := &opencost.AssetSet{
  666. Assets: map[string]opencost.Asset{},
  667. }
  668. result := transformAssetSet(emptySet)
  669. require.NotNil(t, result)
  670. assert.Contains(t, result.Assets, "assets")
  671. assert.Len(t, result.Assets["assets"].Assets, 0)
  672. }
  673. func TestBuildFilterFromParams_EmptyParams(t *testing.T) {
  674. s := &MCPServer{}
  675. params := &CloudCostQuery{}
  676. filter := s.buildFilterFromParams(params)
  677. assert.Nil(t, filter)
  678. }
  679. func TestBuildFilterFromParams_RegionIgnored(t *testing.T) {
  680. s := &MCPServer{}
  681. params := &CloudCostQuery{
  682. Region: "us-east-1", // Should be ignored
  683. }
  684. filter := s.buildFilterFromParams(params)
  685. assert.Nil(t, filter) // Should return nil since only region is set
  686. }
  687. func TestBuildFilterFromParams_EmptyLabelKey(t *testing.T) {
  688. s := &MCPServer{}
  689. params := &CloudCostQuery{
  690. Labels: map[string]string{
  691. "": "value1", // Empty key should be ignored
  692. "valid": "value2",
  693. },
  694. }
  695. filter := s.buildFilterFromParams(params)
  696. assert.NotNil(t, filter)
  697. }
  698. func TestBuildCloudCostQueryRequest_EmptyParams(t *testing.T) {
  699. s := &MCPServer{}
  700. req := cloudcost.QueryRequest{}
  701. params := &CloudCostQuery{}
  702. result := s.buildCloudCostQueryRequest(req, params)
  703. assert.Equal(t, req, result) // Should return unchanged request
  704. }
  705. func TestBuildCloudCostQueryRequest_InvalidFilterString(t *testing.T) {
  706. s := &MCPServer{}
  707. req := cloudcost.QueryRequest{}
  708. params := &CloudCostQuery{
  709. Filter: "invalid filter syntax !!!",
  710. }
  711. result := s.buildCloudCostQueryRequest(req, params)
  712. // Should not panic and should return request with nil filter
  713. assert.Nil(t, result.Filter)
  714. }
  715. func TestQueryCloudCosts_NilCloudQuerier(t *testing.T) {
  716. s := &MCPServer{cloudQuerier: nil}
  717. req := &OpenCostQueryRequest{
  718. QueryType: CloudCostQueryType,
  719. Window: "24h",
  720. }
  721. _, err := s.QueryCloudCosts(context.Background(), req)
  722. require.Error(t, err)
  723. assert.Contains(t, err.Error(), "cloud cost querier not configured")
  724. }
  725. func TestQueryCloudCosts_InvalidWindow(t *testing.T) {
  726. s := &MCPServer{cloudQuerier: &dummyQuerier{}}
  727. req := &OpenCostQueryRequest{
  728. QueryType: CloudCostQueryType,
  729. Window: "invalid-window",
  730. }
  731. _, err := s.QueryCloudCosts(context.Background(), req)
  732. require.Error(t, err)
  733. assert.Contains(t, err.Error(), "failed to parse window")
  734. }
  735. func TestQueryAssets_InvalidWindow(t *testing.T) {
  736. s := &MCPServer{}
  737. req := &OpenCostQueryRequest{
  738. QueryType: AssetQueryType,
  739. Window: "invalid-window",
  740. }
  741. _, err := s.QueryAssets(req)
  742. require.Error(t, err)
  743. assert.Contains(t, err.Error(), "failed to parse window")
  744. }
  745. func TestQueryAllocations_InvalidWindow(t *testing.T) {
  746. s := &MCPServer{}
  747. req := &OpenCostQueryRequest{
  748. QueryType: AllocationQueryType,
  749. Window: "invalid-window",
  750. }
  751. _, err := s.QueryAllocations(req)
  752. require.Error(t, err)
  753. assert.Contains(t, err.Error(), "failed to parse window")
  754. }
  755. func TestProcessMCPRequest_ResponseMetadata(t *testing.T) {
  756. dq := &dummyQuerier{}
  757. s := &MCPServer{cloudQuerier: dq}
  758. req := &MCPRequest{
  759. Query: &OpenCostQueryRequest{
  760. QueryType: CloudCostQueryType,
  761. Window: "1h",
  762. },
  763. }
  764. resp, err := s.ProcessMCPRequest(context.Background(), req)
  765. require.NoError(t, err)
  766. require.NotNil(t, resp)
  767. // Check response metadata
  768. assert.NotEmpty(t, resp.QueryInfo.QueryID)
  769. assert.NotZero(t, resp.QueryInfo.Timestamp)
  770. assert.Greater(t, resp.QueryInfo.ProcessingTime, time.Duration(0))
  771. }
  772. func TestCloudCostQuery_NewFields(t *testing.T) {
  773. query := CloudCostQuery{
  774. InvoiceEntityID: "entity-123",
  775. ProviderID: "provider-456",
  776. Labels: map[string]string{
  777. "environment": "prod",
  778. "team": "platform",
  779. },
  780. }
  781. assert.Equal(t, "entity-123", query.InvoiceEntityID)
  782. assert.Equal(t, "provider-456", query.ProviderID)
  783. assert.Equal(t, "prod", query.Labels["environment"])
  784. assert.Equal(t, "platform", query.Labels["team"])
  785. }
  786. // ---- Tests for Efficiency Tool ----
  787. func TestEfficiencyQueryStruct(t *testing.T) {
  788. bufferMultiplier := 1.4
  789. query := EfficiencyQuery{
  790. Aggregate: "pod",
  791. Filter: "namespace:production",
  792. EfficiencyBufferMultiplier: &bufferMultiplier,
  793. }
  794. assert.Equal(t, "pod", query.Aggregate)
  795. assert.Equal(t, "namespace:production", query.Filter)
  796. assert.NotNil(t, query.EfficiencyBufferMultiplier)
  797. assert.Equal(t, 1.4, *query.EfficiencyBufferMultiplier)
  798. }
  799. func TestEfficiencyQueryDefaultValues(t *testing.T) {
  800. query := EfficiencyQuery{}
  801. assert.Empty(t, query.Aggregate)
  802. assert.Empty(t, query.Filter)
  803. assert.Nil(t, query.EfficiencyBufferMultiplier)
  804. }
  805. func TestEfficiencyMetricStruct(t *testing.T) {
  806. now := time.Now()
  807. metric := EfficiencyMetric{
  808. Name: "test-pod",
  809. CPUEfficiency: 0.5,
  810. MemoryEfficiency: 0.6,
  811. CPUCoresRequested: 2.0,
  812. CPUCoresUsed: 1.0,
  813. RAMBytesRequested: 2147483648, // 2GB
  814. RAMBytesUsed: 1288490188, // ~1.2GB
  815. RecommendedCPURequest: 1.2,
  816. RecommendedRAMRequest: 1546188226, // ~1.44GB
  817. ResultingCPUEfficiency: 0.833,
  818. ResultingMemoryEfficiency: 0.833,
  819. CurrentTotalCost: 10.0,
  820. RecommendedCost: 6.0,
  821. CostSavings: 4.0,
  822. CostSavingsPercent: 40.0,
  823. EfficiencyBufferMultiplier: 1.2,
  824. Start: now.Add(-24 * time.Hour),
  825. End: now,
  826. }
  827. assert.Equal(t, "test-pod", metric.Name)
  828. assert.Equal(t, 0.5, metric.CPUEfficiency)
  829. assert.Equal(t, 0.6, metric.MemoryEfficiency)
  830. assert.Equal(t, 2.0, metric.CPUCoresRequested)
  831. assert.Equal(t, 1.0, metric.CPUCoresUsed)
  832. assert.Equal(t, 2147483648.0, metric.RAMBytesRequested)
  833. assert.Equal(t, 1288490188.0, metric.RAMBytesUsed)
  834. assert.Equal(t, 1.2, metric.RecommendedCPURequest)
  835. assert.Equal(t, 1546188226.0, metric.RecommendedRAMRequest)
  836. assert.Equal(t, 0.833, metric.ResultingCPUEfficiency)
  837. assert.Equal(t, 0.833, metric.ResultingMemoryEfficiency)
  838. assert.Equal(t, 10.0, metric.CurrentTotalCost)
  839. assert.Equal(t, 6.0, metric.RecommendedCost)
  840. assert.Equal(t, 4.0, metric.CostSavings)
  841. assert.Equal(t, 40.0, metric.CostSavingsPercent)
  842. assert.Equal(t, 1.2, metric.EfficiencyBufferMultiplier)
  843. assert.True(t, metric.Start.Before(metric.End))
  844. }
  845. func TestEfficiencyResponseStruct(t *testing.T) {
  846. now := time.Now()
  847. metric1 := &EfficiencyMetric{
  848. Name: "pod-1",
  849. CPUEfficiency: 0.5,
  850. MemoryEfficiency: 0.6,
  851. Start: now.Add(-24 * time.Hour),
  852. End: now,
  853. }
  854. metric2 := &EfficiencyMetric{
  855. Name: "pod-2",
  856. CPUEfficiency: 0.7,
  857. MemoryEfficiency: 0.8,
  858. Start: now.Add(-24 * time.Hour),
  859. End: now,
  860. }
  861. response := EfficiencyResponse{
  862. Efficiencies: []*EfficiencyMetric{metric1, metric2},
  863. }
  864. require.NotNil(t, response.Efficiencies)
  865. assert.Len(t, response.Efficiencies, 2)
  866. assert.Equal(t, "pod-1", response.Efficiencies[0].Name)
  867. assert.Equal(t, "pod-2", response.Efficiencies[1].Name)
  868. }
  869. func TestSafeDiv(t *testing.T) {
  870. tests := []struct {
  871. name string
  872. numerator float64
  873. denominator float64
  874. expected float64
  875. }{
  876. {"normal division", 10.0, 2.0, 5.0},
  877. {"zero denominator", 10.0, 0.0, 0.0},
  878. {"zero numerator", 0.0, 2.0, 0.0},
  879. {"both zero", 0.0, 0.0, 0.0},
  880. {"negative values", -10.0, 2.0, -5.0},
  881. {"fractional result", 5.0, 2.0, 2.5},
  882. }
  883. for _, tt := range tests {
  884. t.Run(tt.name, func(t *testing.T) {
  885. result := safeDiv(tt.numerator, tt.denominator)
  886. assert.Equal(t, tt.expected, result)
  887. })
  888. }
  889. }
  890. func TestComputeEfficiencyMetric_NilAllocation(t *testing.T) {
  891. result := computeEfficiencyMetric(nil, 1.2)
  892. assert.Nil(t, result)
  893. }
  894. func TestComputeEfficiencyMetric_ZeroMinutes(t *testing.T) {
  895. now := time.Now()
  896. alloc := &opencost.Allocation{
  897. Name: "test-pod",
  898. Start: now,
  899. End: now, // Same time, so 0 minutes
  900. }
  901. result := computeEfficiencyMetric(alloc, 1.2)
  902. assert.Nil(t, result)
  903. }
  904. func TestComputeEfficiencyMetric_ValidAllocation(t *testing.T) {
  905. now := time.Now()
  906. alloc := &opencost.Allocation{
  907. Name: "test-pod",
  908. Start: now.Add(-24 * time.Hour),
  909. End: now,
  910. // 24 hours = 1440 minutes
  911. CPUCoreHours: 24.0, // 1 core for 24 hours
  912. RAMByteHours: 24.0e9, // ~1GB for 24 hours
  913. CPUCoreRequestAverage: 2.0, // Requested 2 cores
  914. RAMBytesRequestAverage: 2.0e9, // Requested 2GB
  915. CPUCost: 10.0,
  916. RAMCost: 5.0,
  917. }
  918. result := computeEfficiencyMetric(alloc, 1.2)
  919. require.NotNil(t, result)
  920. assert.Equal(t, "test-pod", result.Name)
  921. assert.Equal(t, 2.0, result.CPUCoresRequested)
  922. assert.Equal(t, 2.0e9, result.RAMBytesRequested)
  923. assert.Equal(t, 1.0, result.CPUCoresUsed) // 24 core-hours / 24 hours = 1 core
  924. assert.Equal(t, 1.0e9, result.RAMBytesUsed) // 24GB-hours / 24 hours = 1GB
  925. assert.Equal(t, 0.5, result.CPUEfficiency) // 1 / 2 = 0.5
  926. assert.Equal(t, 0.5, result.MemoryEfficiency) // 1GB / 2GB = 0.5
  927. assert.Equal(t, 1.2, result.RecommendedCPURequest) // 1 * 1.2 = 1.2
  928. assert.Equal(t, 1.2e9, result.RecommendedRAMRequest) // 1GB * 1.2 = 1.2GB
  929. assert.Equal(t, 1.2, result.EfficiencyBufferMultiplier)
  930. assert.Greater(t, result.CostSavings, 0.0)
  931. }
  932. func TestComputeEfficiencyMetric_CustomBufferMultiplier(t *testing.T) {
  933. now := time.Now()
  934. alloc := &opencost.Allocation{
  935. Name: "test-pod",
  936. Start: now.Add(-24 * time.Hour),
  937. End: now,
  938. CPUCoreHours: 24.0,
  939. RAMByteHours: 24.0e9,
  940. CPUCoreRequestAverage: 2.0,
  941. RAMBytesRequestAverage: 2.0e9,
  942. CPUCost: 10.0,
  943. RAMCost: 5.0,
  944. }
  945. // Test with 1.4 buffer multiplier (40% headroom)
  946. result := computeEfficiencyMetric(alloc, 1.4)
  947. require.NotNil(t, result)
  948. assert.Equal(t, 1.4, result.RecommendedCPURequest) // 1 * 1.4 = 1.4
  949. assert.Equal(t, 1.4e9, result.RecommendedRAMRequest) // 1GB * 1.4 = 1.4GB
  950. assert.Equal(t, 1.4, result.EfficiencyBufferMultiplier)
  951. // Resulting efficiency should be usage / recommended
  952. expectedCPUEff := 1.0 / 1.4
  953. expectedMemEff := 1.0e9 / 1.4e9
  954. assert.InDelta(t, expectedCPUEff, result.ResultingCPUEfficiency, 0.001)
  955. assert.InDelta(t, expectedMemEff, result.ResultingMemoryEfficiency, 0.001)
  956. }
  957. func TestComputeEfficiencyMetric_MinimumThresholds(t *testing.T) {
  958. now := time.Now()
  959. alloc := &opencost.Allocation{
  960. Name: "test-pod",
  961. Start: now.Add(-24 * time.Hour),
  962. End: now,
  963. // Very small usage
  964. CPUCoreHours: 0.00001, // 0.000000417 cores average
  965. RAMByteHours: 100, // ~4 bytes average
  966. CPUCoreRequestAverage: 0.1,
  967. RAMBytesRequestAverage: 1000,
  968. CPUCost: 0.001,
  969. RAMCost: 0.001,
  970. }
  971. result := computeEfficiencyMetric(alloc, 1.2)
  972. require.NotNil(t, result)
  973. // Should enforce minimum CPU (0.001 cores)
  974. assert.Equal(t, efficiencyMinCPU, result.RecommendedCPURequest)
  975. // Should enforce minimum RAM (1MB)
  976. assert.Equal(t, float64(efficiencyMinRAM), result.RecommendedRAMRequest)
  977. }
  978. func TestComputeEfficiencyMetric_NoRequests(t *testing.T) {
  979. now := time.Now()
  980. alloc := &opencost.Allocation{
  981. Name: "test-pod",
  982. Start: now.Add(-24 * time.Hour),
  983. End: now,
  984. CPUCoreHours: 24.0,
  985. RAMByteHours: 24.0e9,
  986. CPUCoreRequestAverage: 0.0, // No requests set
  987. RAMBytesRequestAverage: 0.0, // No requests set
  988. CPUCost: 10.0,
  989. RAMCost: 5.0,
  990. }
  991. result := computeEfficiencyMetric(alloc, 1.2)
  992. require.NotNil(t, result)
  993. // Efficiency should be 0 when no requests are set
  994. assert.Equal(t, 0.0, result.CPUEfficiency)
  995. assert.Equal(t, 0.0, result.MemoryEfficiency)
  996. // Recommendations should still be calculated based on usage
  997. assert.Equal(t, 1.2, result.RecommendedCPURequest)
  998. assert.Equal(t, 1.2e9, result.RecommendedRAMRequest)
  999. }
  1000. func TestComputeEfficiencyMetric_OverProvisioned(t *testing.T) {
  1001. now := time.Now()
  1002. alloc := &opencost.Allocation{
  1003. Name: "test-pod",
  1004. Start: now.Add(-24 * time.Hour),
  1005. End: now,
  1006. CPUCoreHours: 12.0, // 0.5 cores average
  1007. RAMByteHours: 12.0e9, // 0.5GB average
  1008. CPUCoreRequestAverage: 4.0, // Requested 4 cores (over-provisioned)
  1009. RAMBytesRequestAverage: 8.0e9, // Requested 8GB (over-provisioned)
  1010. CPUCost: 40.0,
  1011. RAMCost: 20.0,
  1012. }
  1013. result := computeEfficiencyMetric(alloc, 1.2)
  1014. require.NotNil(t, result)
  1015. // Low efficiency due to over-provisioning
  1016. assert.Equal(t, 0.125, result.CPUEfficiency) // 0.5 / 4 = 0.125
  1017. assert.Equal(t, 0.0625, result.MemoryEfficiency) // 0.5GB / 8GB = 0.0625
  1018. // Recommendations should be much lower
  1019. assert.Equal(t, 0.6, result.RecommendedCPURequest) // 0.5 * 1.2 = 0.6
  1020. assert.Equal(t, 0.6e9, result.RecommendedRAMRequest) // 0.5GB * 1.2 = 0.6GB
  1021. // Should have significant cost savings
  1022. assert.Greater(t, result.CostSavings, 0.0)
  1023. assert.Greater(t, result.CostSavingsPercent, 50.0)
  1024. }
  1025. func TestComputeEfficiencyMetric_UnderProvisioned(t *testing.T) {
  1026. now := time.Now()
  1027. alloc := &opencost.Allocation{
  1028. Name: "test-pod",
  1029. Start: now.Add(-24 * time.Hour),
  1030. End: now,
  1031. CPUCoreHours: 48.0, // 2 cores average
  1032. RAMByteHours: 48.0e9, // 2GB average
  1033. CPUCoreRequestAverage: 1.0, // Requested 1 core (under-provisioned)
  1034. RAMBytesRequestAverage: 1.0e9, // Requested 1GB (under-provisioned)
  1035. CPUCost: 10.0,
  1036. RAMCost: 5.0,
  1037. }
  1038. result := computeEfficiencyMetric(alloc, 1.2)
  1039. require.NotNil(t, result)
  1040. // High efficiency (>100%) due to under-provisioning
  1041. assert.Equal(t, 2.0, result.CPUEfficiency) // 2 / 1 = 2.0
  1042. assert.Equal(t, 2.0, result.MemoryEfficiency) // 2GB / 1GB = 2.0
  1043. // Recommendations should be higher than current requests
  1044. assert.Equal(t, 2.4, result.RecommendedCPURequest) // 2 * 1.2 = 2.4
  1045. assert.Equal(t, 2.4e9, result.RecommendedRAMRequest) // 2GB * 1.2 = 2.4GB
  1046. }
  1047. func TestComputeEfficiencyMetric_CostCalculations(t *testing.T) {
  1048. now := time.Now()
  1049. alloc := &opencost.Allocation{
  1050. Name: "test-pod",
  1051. Start: now.Add(-24 * time.Hour),
  1052. End: now,
  1053. CPUCoreHours: 24.0,
  1054. RAMByteHours: 24.0e9,
  1055. CPUCoreRequestAverage: 2.0,
  1056. RAMBytesRequestAverage: 2.0e9,
  1057. CPUCost: 10.0, // $10 for CPU
  1058. RAMCost: 5.0, // $5 for RAM
  1059. NetworkCost: 1.0, // $1 for network
  1060. SharedCost: 0.5, // $0.5 shared
  1061. ExternalCost: 0.5, // $0.5 external
  1062. GPUCost: 1.0, // $1 for GPU
  1063. }
  1064. result := computeEfficiencyMetric(alloc, 1.2)
  1065. require.NotNil(t, result)
  1066. // Current total cost should include all costs
  1067. expectedCurrentCost := 10.0 + 5.0 + 1.0 + 0.5 + 0.5 + 1.0 // = 18.0
  1068. assert.Equal(t, expectedCurrentCost, result.CurrentTotalCost)
  1069. // Recommended cost should be lower due to right-sizing
  1070. assert.Less(t, result.RecommendedCost, result.CurrentTotalCost)
  1071. // Cost savings should be positive
  1072. assert.Greater(t, result.CostSavings, 0.0)
  1073. assert.Equal(t, result.CurrentTotalCost-result.RecommendedCost, result.CostSavings)
  1074. // Cost savings percent should be calculated correctly
  1075. expectedPercent := (result.CostSavings / result.CurrentTotalCost) * 100
  1076. assert.InDelta(t, expectedPercent, result.CostSavingsPercent, 0.001)
  1077. }
  1078. func TestComputeEfficiencyMetric_OtherCostsPreserved(t *testing.T) {
  1079. now := time.Now()
  1080. alloc := &opencost.Allocation{
  1081. Name: "test-pod",
  1082. Start: now.Add(-24 * time.Hour),
  1083. End: now,
  1084. CPUCoreHours: 24.0,
  1085. RAMByteHours: 24.0e9,
  1086. CPUCoreRequestAverage: 2.0,
  1087. RAMBytesRequestAverage: 2.0e9,
  1088. CPUCost: 10.0,
  1089. RAMCost: 5.0,
  1090. NetworkCost: 2.0, // Fixed cost
  1091. SharedCost: 1.0, // Fixed cost
  1092. ExternalCost: 1.0, // Fixed cost
  1093. GPUCost: 0.0,
  1094. }
  1095. result := computeEfficiencyMetric(alloc, 1.2)
  1096. require.NotNil(t, result)
  1097. // The "other costs" (Network, Shared, External, GPU) should be preserved
  1098. // in the recommended cost calculation
  1099. otherCosts := 2.0 + 1.0 + 1.0 + 0.0 // = 4.0
  1100. // CPU and RAM costs should be reduced based on right-sizing
  1101. // Original: 10.0 + 5.0 = 15.0
  1102. // Usage: 1 core + 1GB
  1103. // Recommended: 1.2 cores + 1.2GB
  1104. // Cost is calculated based on REQUESTED amounts (2 cores, 2GB)
  1105. cpuCostPerCoreHour := 10.0 / (2.0 * 24.0) // CPU cost / (requested cores * hours)
  1106. ramCostPerByteHour := 5.0 / (2.0e9 * 24.0) // RAM cost / (requested bytes * hours)
  1107. expectedRecommendedCPUCost := 1.2 * 24.0 * cpuCostPerCoreHour
  1108. expectedRecommendedRAMCost := 1.2e9 * 24.0 * ramCostPerByteHour
  1109. expectedRecommendedTotal := expectedRecommendedCPUCost + expectedRecommendedRAMCost + otherCosts
  1110. assert.InDelta(t, expectedRecommendedTotal, result.RecommendedCost, 0.01)
  1111. }
  1112. func TestQueryEfficiency_InvalidWindow(t *testing.T) {
  1113. s := &MCPServer{}
  1114. req := &OpenCostQueryRequest{
  1115. QueryType: EfficiencyQueryType,
  1116. Window: "invalid-window",
  1117. }
  1118. _, err := s.QueryEfficiency(req)
  1119. require.Error(t, err)
  1120. assert.Contains(t, err.Error(), "failed to parse window")
  1121. }
  1122. func TestQueryEfficiency_DefaultBufferMultiplier(t *testing.T) {
  1123. // Test that default buffer multiplier is 1.2 when not specified
  1124. req := &OpenCostQueryRequest{
  1125. QueryType: EfficiencyQueryType,
  1126. Window: "24h",
  1127. EfficiencyParams: &EfficiencyQuery{
  1128. // EfficiencyBufferMultiplier not set - should default to 1.2
  1129. },
  1130. }
  1131. assert.Nil(t, req.EfficiencyParams.EfficiencyBufferMultiplier)
  1132. }
  1133. func TestQueryEfficiency_CustomBufferMultiplier(t *testing.T) {
  1134. bufferMultiplier := 1.4
  1135. req := &OpenCostQueryRequest{
  1136. QueryType: EfficiencyQueryType,
  1137. Window: "24h",
  1138. EfficiencyParams: &EfficiencyQuery{
  1139. EfficiencyBufferMultiplier: &bufferMultiplier,
  1140. },
  1141. }
  1142. assert.NotNil(t, req.EfficiencyParams.EfficiencyBufferMultiplier)
  1143. assert.Equal(t, 1.4, *req.EfficiencyParams.EfficiencyBufferMultiplier)
  1144. }
  1145. func TestQueryEfficiency_WithFilter(t *testing.T) {
  1146. req := &OpenCostQueryRequest{
  1147. QueryType: EfficiencyQueryType,
  1148. Window: "7d",
  1149. EfficiencyParams: &EfficiencyQuery{
  1150. Aggregate: "pod",
  1151. Filter: "namespace:production",
  1152. },
  1153. }
  1154. assert.Equal(t, "pod", req.EfficiencyParams.Aggregate)
  1155. assert.Equal(t, "namespace:production", req.EfficiencyParams.Filter)
  1156. }
  1157. func TestQueryEfficiency_WithAggregation(t *testing.T) {
  1158. req := &OpenCostQueryRequest{
  1159. QueryType: EfficiencyQueryType,
  1160. Window: "7d",
  1161. EfficiencyParams: &EfficiencyQuery{
  1162. Aggregate: "namespace,controller",
  1163. },
  1164. }
  1165. assert.Equal(t, "namespace,controller", req.EfficiencyParams.Aggregate)
  1166. }
  1167. func TestEfficiencyConstants(t *testing.T) {
  1168. // Test that efficiency constants are defined correctly
  1169. assert.Equal(t, 1.2, efficiencyBufferMultiplier)
  1170. assert.Equal(t, 0.001, efficiencyMinCPU)
  1171. assert.Equal(t, 1024*1024, efficiencyMinRAM)
  1172. }
  1173. func TestEfficiencyQueryType(t *testing.T) {
  1174. assert.Equal(t, QueryType("efficiency"), EfficiencyQueryType)
  1175. }
  1176. // TestTransformCloudCostSetRange_NilPointerHandling verifies that nil pointer dereferences
  1177. // are prevented in transformCloudCostSetRange for issue #3502
  1178. func TestTransformCloudCostSetRange_NilPointerHandling(t *testing.T) {
  1179. now := time.Now().UTC()
  1180. start := now.Add(-24 * time.Hour)
  1181. end := now
  1182. tests := []struct {
  1183. name string
  1184. ccsr *opencost.CloudCostSetRange
  1185. expectedCostCount int
  1186. expectEmpty bool
  1187. }{
  1188. {
  1189. name: "nil CloudCostSetRange",
  1190. ccsr: nil,
  1191. expectEmpty: true,
  1192. },
  1193. {
  1194. name: "nil CloudCostSet in slice",
  1195. ccsr: &opencost.CloudCostSetRange{CloudCostSets: []*opencost.CloudCostSet{nil}},
  1196. expectEmpty: true,
  1197. },
  1198. {
  1199. name: "CloudCostSet with nil Window.Start",
  1200. ccsr: &opencost.CloudCostSetRange{
  1201. CloudCostSets: []*opencost.CloudCostSet{
  1202. {CloudCosts: map[string]*opencost.CloudCost{}, Window: opencost.NewWindow(nil, &end)},
  1203. },
  1204. },
  1205. expectEmpty: true,
  1206. },
  1207. {
  1208. name: "CloudCostSet with nil Window.End",
  1209. ccsr: &opencost.CloudCostSetRange{
  1210. CloudCostSets: []*opencost.CloudCostSet{
  1211. {CloudCosts: map[string]*opencost.CloudCost{}, Window: opencost.NewWindow(&start, nil)},
  1212. },
  1213. },
  1214. expectEmpty: true,
  1215. },
  1216. {
  1217. name: "CloudCost item with nil Window.Start",
  1218. ccsr: &opencost.CloudCostSetRange{
  1219. CloudCostSets: []*opencost.CloudCostSet{
  1220. {
  1221. CloudCosts: map[string]*opencost.CloudCost{
  1222. "cost1": {
  1223. Properties: &opencost.CloudCostProperties{Provider: "aws"},
  1224. Window: opencost.NewWindow(nil, &end),
  1225. NetCost: opencost.CostMetric{Cost: 100.0},
  1226. },
  1227. },
  1228. Window: opencost.NewClosedWindow(start, end),
  1229. },
  1230. },
  1231. },
  1232. expectedCostCount: 0,
  1233. },
  1234. {
  1235. name: "Mixed valid and invalid items",
  1236. ccsr: &opencost.CloudCostSetRange{
  1237. CloudCostSets: []*opencost.CloudCostSet{
  1238. {
  1239. CloudCosts: map[string]*opencost.CloudCost{
  1240. "invalid": nil,
  1241. "valid": {
  1242. Properties: &opencost.CloudCostProperties{Provider: "gcp"},
  1243. Window: opencost.NewClosedWindow(start, end),
  1244. NetCost: opencost.CostMetric{Cost: 200.0, KubernetesPercent: 0.5},
  1245. },
  1246. },
  1247. Window: opencost.NewClosedWindow(start, end),
  1248. },
  1249. },
  1250. },
  1251. expectedCostCount: 1,
  1252. },
  1253. }
  1254. for _, tt := range tests {
  1255. t.Run(tt.name, func(t *testing.T) {
  1256. // Should not panic
  1257. result := transformCloudCostSetRange(tt.ccsr)
  1258. require.NotNil(t, result)
  1259. require.NotNil(t, result.CloudCosts)
  1260. require.NotNil(t, result.Summary)
  1261. if tt.expectEmpty {
  1262. assert.Empty(t, result.CloudCosts)
  1263. assert.Equal(t, 0.0, result.Summary.TotalNetCost)
  1264. } else {
  1265. totalCosts := 0
  1266. for _, set := range result.CloudCosts {
  1267. totalCosts += len(set.CloudCosts)
  1268. }
  1269. assert.Equal(t, tt.expectedCostCount, totalCosts)
  1270. }
  1271. })
  1272. }
  1273. }
  1274. // contextAwareQuerier is a mock querier that checks for context cancellation
  1275. type contextAwareQuerier struct {
  1276. contextWasCancelled bool
  1277. }
  1278. func (caq *contextAwareQuerier) Query(ctx context.Context, req cloudcost.QueryRequest) (*opencost.CloudCostSetRange, error) {
  1279. // Check if context is already cancelled
  1280. select {
  1281. case <-ctx.Done():
  1282. caq.contextWasCancelled = true
  1283. return nil, ctx.Err()
  1284. default:
  1285. // Return empty set range
  1286. ccsr, _ := opencost.NewCloudCostSetRange(time.Now().Add(-24*time.Hour), time.Now(), opencost.AccumulateOptionDay, "")
  1287. return ccsr, nil
  1288. }
  1289. }
  1290. func (caq *contextAwareQuerier) QueryCloudCostAutocomplete(ctx context.Context, _ cloudcost.CloudCostAutocompleteRequest) (*cloudcost.CloudCostAutocompleteResponse, error) {
  1291. select {
  1292. case <-ctx.Done():
  1293. caq.contextWasCancelled = true
  1294. return nil, ctx.Err()
  1295. default:
  1296. return &cloudcost.CloudCostAutocompleteResponse{Data: []string{}}, nil
  1297. }
  1298. }
  1299. func TestQueryCloudCosts_ContextCancellation(t *testing.T) {
  1300. // Create a context that is already cancelled
  1301. ctx, cancel := context.WithCancel(context.Background())
  1302. cancel() // Cancel immediately
  1303. // Create a context-aware mock querier
  1304. caq := &contextAwareQuerier{}
  1305. s := &MCPServer{cloudQuerier: caq}
  1306. req := &OpenCostQueryRequest{
  1307. QueryType: CloudCostQueryType,
  1308. Window: "1d",
  1309. }
  1310. // Query should fail with context cancelled error
  1311. _, err := s.QueryCloudCosts(ctx, req)
  1312. // Verify context cancellation was detected
  1313. assert.Error(t, err)
  1314. assert.True(t, caq.contextWasCancelled, "Context cancellation should be detected by querier")
  1315. assert.ErrorIs(t, err, context.Canceled, "Error should be context.Canceled")
  1316. }
  1317. func TestProcessMCPRequest_ContextPropagation(t *testing.T) {
  1318. // Test that context is properly propagated through ProcessMCPRequest
  1319. ctx := context.Background()
  1320. dq := &dummyQuerier{}
  1321. s := &MCPServer{cloudQuerier: dq}
  1322. req := &MCPRequest{
  1323. Query: &OpenCostQueryRequest{
  1324. QueryType: CloudCostQueryType,
  1325. Window: "1d",
  1326. },
  1327. }
  1328. resp, err := s.ProcessMCPRequest(ctx, req)
  1329. require.NoError(t, err)
  1330. require.NotNil(t, resp)
  1331. // Verify that the querier was called (context was propagated)
  1332. assert.NotNil(t, dq.last)
  1333. }