server_test.go 47 KB

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