costmodel.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. package costmodel
  2. import (
  3. "context"
  4. "fmt"
  5. "net/http"
  6. "os"
  7. "os/signal"
  8. "syscall"
  9. "time"
  10. "github.com/julienschmidt/httprouter"
  11. "github.com/opencost/opencost/core/pkg/util/apiutil"
  12. "github.com/opencost/opencost/core/pkg/util/timeutil"
  13. "github.com/opencost/opencost/pkg/cloudcost"
  14. "github.com/opencost/opencost/pkg/customcost"
  15. "github.com/prometheus/client_golang/prometheus/promhttp"
  16. "github.com/rs/cors"
  17. mcp_sdk "github.com/modelcontextprotocol/go-sdk/mcp"
  18. "github.com/opencost/opencost/core/pkg/errors"
  19. "github.com/opencost/opencost/core/pkg/log"
  20. "github.com/opencost/opencost/core/pkg/version"
  21. "github.com/opencost/opencost/pkg/costmodel"
  22. "github.com/opencost/opencost/pkg/env"
  23. "github.com/opencost/opencost/pkg/filemanager"
  24. "github.com/opencost/opencost/pkg/inferencecost"
  25. opencost_mcp "github.com/opencost/opencost/pkg/mcp"
  26. "github.com/opencost/opencost/pkg/metrics"
  27. )
  28. const shutdownTimeout = 30 * time.Second
  29. func Execute(conf *Config) error {
  30. log.Infof("Starting cost-model version %s", version.FriendlyVersion())
  31. if conf == nil {
  32. conf = DefaultConfig()
  33. }
  34. conf.log()
  35. // Create cancellable context for graceful shutdown
  36. ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  37. defer cancel()
  38. router := httprouter.New()
  39. var a *costmodel.Accesses
  40. if conf.KubernetesEnabled {
  41. a = costmodel.Initialize(router)
  42. err := StartExportWorker(context.Background(), a.Model)
  43. if err != nil {
  44. log.Errorf("couldn't start CSV export worker: %v", err)
  45. }
  46. // Register inference cost routes unconditionally so clients receive 501
  47. // (not 404) when INFERENCE_COST_ENABLED=false. The QueryService nil-
  48. // checks in each handler produce the 501 when qs is nil.
  49. var inferenceCostQueryService *inferencecost.QueryService
  50. if conf.InferenceCostEnabled {
  51. if err := StartInferenceCostCollector(ctx, a, &inferenceCostQueryService); err != nil {
  52. log.Errorf("Failed to start inference cost collector: %v", err)
  53. }
  54. }
  55. router.GET("/inferenceCost/total", inferenceCostQueryService.GetInferenceCostTotalHandler())
  56. router.GET("/inferenceCost/timeseries", inferenceCostQueryService.GetInferenceCostTimeseriesHandler())
  57. // Register OpenCost Specific Endpoints
  58. router.GET("/allocation", a.ComputeAllocationHandler)
  59. router.GET("/allocation/summary", a.ComputeAllocationHandlerSummary)
  60. router.GET("/assets", a.ComputeAssetsHandler)
  61. if conf.CarbonEstimatesEnabled {
  62. router.GET("/assets/carbon", a.ComputeAssetsCarbonHandler)
  63. }
  64. router.GET("/kubemodel", a.KubeModelHandler)
  65. }
  66. var cloudCostPipelineService *cloudcost.PipelineService
  67. if conf.CloudCostEnabled {
  68. cloudCostPipelineService = costmodel.InitializeCloudCost(router)
  69. }
  70. var customCostPipelineService *customcost.PipelineService
  71. if conf.CustomCostEnabled {
  72. customCostPipelineService = costmodel.InitializeCustomCost(router)
  73. }
  74. // this endpoint is intentionally left out of the "if env.IsCustomCostEnabled()" conditional; in the handler, it is
  75. // valid for CustomCostPipelineService to be nil
  76. router.GET("/customCost/status", customCostPipelineService.GetCustomCostStatusHandler())
  77. // Initialize MCP Server if enabled and Kubernetes is available
  78. if conf.MCPServerEnabled && a != nil {
  79. // Get cloud cost querier if cloud costs are enabled
  80. var cloudCostQuerier cloudcost.Querier
  81. if conf.CloudCostEnabled && cloudCostPipelineService != nil {
  82. cloudCostQuerier = cloudCostPipelineService.GetCloudCostQuerier()
  83. }
  84. err := StartMCPServer(ctx, a, cloudCostQuerier)
  85. if err != nil {
  86. log.Errorf("Failed to start MCP server: %v", err)
  87. }
  88. } else if conf.MCPServerEnabled {
  89. log.Warnf("MCP Server is enabled but Kubernetes is not available. MCP server requires Kubernetes to function.")
  90. } else {
  91. if value, exists := os.LookupEnv(env.MCPServerEnabledEnvVar); !exists || value == "" {
  92. log.Infof("MCP server is now disabled by default. If you wish to use the MCP server, please set the %s environment variable to true.", env.MCPServerEnabledEnvVar)
  93. }
  94. }
  95. apiutil.ApplyContainerDiagnosticEndpoints(router)
  96. rootMux := http.NewServeMux()
  97. rootMux.Handle("/", router)
  98. rootMux.Handle("/metrics", promhttp.Handler())
  99. telemetryHandler := metrics.ResponseMetricMiddleware(rootMux)
  100. handler := cors.AllowAll().Handler(telemetryHandler)
  101. server := &http.Server{
  102. Addr: fmt.Sprint(":", conf.Port),
  103. Handler: errors.PanicHandlerMiddleware(handler),
  104. }
  105. serverErrors := make(chan error, 1)
  106. go func() {
  107. log.Infof("HTTP server starting on port %d", conf.Port)
  108. serverErrors <- server.ListenAndServe()
  109. }()
  110. select {
  111. case err := <-serverErrors:
  112. if err != nil && err != http.ErrServerClosed {
  113. return err
  114. }
  115. return nil
  116. case <-ctx.Done():
  117. log.Infof("Shutdown signal received, starting graceful shutdown...")
  118. if a.KubeModelPipeline != nil {
  119. a.KubeModelPipeline.Stop()
  120. }
  121. if customCostPipelineService != nil {
  122. customCostPipelineService.Stop()
  123. }
  124. shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
  125. defer shutdownCancel()
  126. if err := server.Shutdown(shutdownCtx); err != nil {
  127. log.Errorf("Error during server shutdown: %v", err)
  128. server.Close()
  129. return err
  130. }
  131. log.Infof("Graceful shutdown completed")
  132. return nil
  133. }
  134. }
  135. // StartInferenceCostCollector initialises and starts the inference cost
  136. // collection loop as a background goroutine, and populates *qs with the
  137. // QueryService so the caller can register routes. It is a no-op if the
  138. // collector cannot be initialised (error is logged, existing functionality
  139. // is unaffected).
  140. func StartInferenceCostCollector(ctx context.Context, a *costmodel.Accesses, qs **inferencecost.QueryService) error {
  141. cfg := inferencecost.DefaultConfig()
  142. // Get the MetricsQuerier from the DataSource
  143. metricsQuerier := a.DataSource.Metrics()
  144. collector, err := inferencecost.NewCollector(cfg, a.Model, metricsQuerier)
  145. if err != nil {
  146. return err
  147. }
  148. exporter := inferencecost.NewExporter()
  149. if err := exporter.Register(); err != nil {
  150. return err
  151. }
  152. calculator := inferencecost.NewCalculator(cfg)
  153. runner := inferencecost.NewRunner(collector, calculator, exporter, cfg.CollectionInterval)
  154. // The collector and calculator are shared between the background runner
  155. // and the API; both paths are read-only, so sharing is safe.
  156. *qs = inferencecost.NewQueryService(collector, calculator)
  157. go runner.Start(ctx)
  158. log.Infof("InferenceCost: collector started (interval=%s)", cfg.CollectionInterval)
  159. return nil
  160. }
  161. func StartExportWorker(ctx context.Context, model costmodel.AllocationModel) error {
  162. exportPath := env.GetExportCSVFile()
  163. if exportPath == "" {
  164. log.Infof("%s is not set, CSV export is disabled", env.ExportCSVFile)
  165. return nil
  166. }
  167. fm, err := filemanager.NewFileManager(exportPath)
  168. if err != nil {
  169. return fmt.Errorf("could not create file manager: %v", err)
  170. }
  171. go func() {
  172. log.Info("Starting CSV exporter worker...")
  173. // perform first update immediately
  174. nextRunAt := time.Now()
  175. for {
  176. select {
  177. case <-ctx.Done():
  178. return
  179. case <-time.After(time.Until(nextRunAt)):
  180. err := costmodel.UpdateCSV(ctx, fm, model, env.GetExportCSVLabelsAll(), env.GetExportCSVLabelsList())
  181. if err != nil {
  182. // it's background worker, log error and carry on, maybe next time it will work
  183. log.Errorf("Error updating CSV: %s", err)
  184. }
  185. now := time.Now().UTC()
  186. // next launch is at 00:10 UTC tomorrow
  187. // extra 10 minutes is to let prometheus to collect all the data for the previous day
  188. nextRunAt = time.Date(now.Year(), now.Month(), now.Day(), 0, 10, 0, 0, now.Location()).AddDate(0, 0, 1)
  189. }
  190. }
  191. }()
  192. return nil
  193. }
  194. // StartMCPServer starts the MCP server as a background service
  195. func StartMCPServer(ctx context.Context, accesses *costmodel.Accesses, cloudCostQuerier cloudcost.Querier) error {
  196. log.Info("Initializing MCP server...")
  197. // Create MCP server using existing OpenCost dependencies
  198. mcpServer := opencost_mcp.NewMCPServer(accesses.Model, accesses.CloudProvider, cloudCostQuerier)
  199. // Create MCP SDK server
  200. sdkServer := mcp_sdk.NewServer(&mcp_sdk.Implementation{
  201. Name: "opencost-mcp-server",
  202. Version: version.Version,
  203. }, nil)
  204. // Define tool handlers
  205. handleAllocationCosts := func(ctx context.Context, req *mcp_sdk.CallToolRequest, args AllocationArgs) (*mcp_sdk.CallToolResult, interface{}, error) {
  206. var step time.Duration
  207. if args.Step != "" {
  208. var err error
  209. step, err = timeutil.ParseDuration(args.Step)
  210. if err != nil {
  211. return nil, nil, fmt.Errorf("invalid step duration '%s': %w", args.Step, err)
  212. }
  213. if step <= 0 {
  214. return nil, nil, fmt.Errorf("invalid step duration '%s': must be > 0", args.Step)
  215. }
  216. }
  217. queryRequest := &opencost_mcp.OpenCostQueryRequest{
  218. QueryType: opencost_mcp.AllocationQueryType,
  219. Window: args.Window,
  220. AllocationParams: &opencost_mcp.AllocationQuery{
  221. Step: step,
  222. Accumulate: args.Accumulate,
  223. ShareIdle: args.ShareIdle,
  224. Aggregate: args.Aggregate,
  225. IncludeIdle: args.IncludeIdle,
  226. IdleByNode: args.IdleByNode,
  227. IncludeProportionalAssetResourceCosts: args.IncludeProportionalAssetResourceCosts,
  228. IncludeAggregatedMetadata: args.IncludeAggregatedMetadata,
  229. ShareLB: args.ShareLB,
  230. Filter: args.Filter,
  231. },
  232. }
  233. mcpReq := &opencost_mcp.MCPRequest{
  234. Query: queryRequest,
  235. }
  236. mcpResp, err := mcpServer.ProcessMCPRequest(ctx, mcpReq)
  237. if err != nil {
  238. return nil, nil, fmt.Errorf("failed to process allocation request: %w", err)
  239. }
  240. return nil, mcpResp, nil
  241. }
  242. handleAssetCosts := func(ctx context.Context, req *mcp_sdk.CallToolRequest, args AssetArgs) (*mcp_sdk.CallToolResult, interface{}, error) {
  243. queryRequest := &opencost_mcp.OpenCostQueryRequest{
  244. QueryType: opencost_mcp.AssetQueryType,
  245. Window: args.Window,
  246. AssetParams: &opencost_mcp.AssetQuery{},
  247. }
  248. mcpReq := &opencost_mcp.MCPRequest{
  249. Query: queryRequest,
  250. }
  251. mcpResp, err := mcpServer.ProcessMCPRequest(ctx, mcpReq)
  252. if err != nil {
  253. return nil, nil, fmt.Errorf("failed to process asset request: %w", err)
  254. }
  255. return nil, mcpResp, nil
  256. }
  257. handleCloudCosts := func(ctx context.Context, req *mcp_sdk.CallToolRequest, args CloudCostArgs) (*mcp_sdk.CallToolResult, interface{}, error) {
  258. queryRequest := &opencost_mcp.OpenCostQueryRequest{
  259. QueryType: opencost_mcp.CloudCostQueryType,
  260. Window: args.Window,
  261. CloudCostParams: &opencost_mcp.CloudCostQuery{
  262. Aggregate: args.Aggregate,
  263. Accumulate: args.Accumulate,
  264. Filter: args.Filter,
  265. Provider: args.Provider,
  266. Service: args.Service,
  267. Category: args.Category,
  268. Region: args.Region,
  269. AccountID: args.Account,
  270. },
  271. }
  272. mcpReq := &opencost_mcp.MCPRequest{
  273. Query: queryRequest,
  274. }
  275. mcpResp, err := mcpServer.ProcessMCPRequest(ctx, mcpReq)
  276. if err != nil {
  277. return nil, nil, fmt.Errorf("failed to process cloud cost request: %w", err)
  278. }
  279. return nil, mcpResp, nil
  280. }
  281. handleEfficiency := func(ctx context.Context, req *mcp_sdk.CallToolRequest, args EfficiencyArgs) (*mcp_sdk.CallToolResult, interface{}, error) {
  282. var step time.Duration
  283. if args.Step != "" {
  284. var err error
  285. step, err = timeutil.ParseDuration(args.Step)
  286. if err != nil {
  287. return nil, nil, fmt.Errorf("invalid step duration '%s': %w", args.Step, err)
  288. }
  289. if step <= 0 {
  290. return nil, nil, fmt.Errorf("invalid step duration '%s': must be > 0", args.Step)
  291. }
  292. }
  293. queryRequest := &opencost_mcp.OpenCostQueryRequest{
  294. QueryType: opencost_mcp.EfficiencyQueryType,
  295. Window: args.Window,
  296. EfficiencyParams: &opencost_mcp.EfficiencyQuery{
  297. Step: step,
  298. Aggregate: args.Aggregate,
  299. Filter: args.Filter,
  300. EfficiencyBufferMultiplier: args.BufferMultiplier,
  301. },
  302. }
  303. mcpReq := &opencost_mcp.MCPRequest{
  304. Query: queryRequest,
  305. }
  306. mcpResp, err := mcpServer.ProcessMCPRequest(ctx, mcpReq)
  307. if err != nil {
  308. return nil, nil, fmt.Errorf("failed to process efficiency request: %w", err)
  309. }
  310. return nil, mcpResp, nil
  311. }
  312. // Register tools
  313. mcp_sdk.AddTool(sdkServer, &mcp_sdk.Tool{
  314. Name: "get_allocation_costs",
  315. Description: "Retrieves allocation cost data.",
  316. }, handleAllocationCosts)
  317. mcp_sdk.AddTool(sdkServer, &mcp_sdk.Tool{
  318. Name: "get_asset_costs",
  319. Description: "Retrieves asset cost data.",
  320. }, handleAssetCosts)
  321. mcp_sdk.AddTool(sdkServer, &mcp_sdk.Tool{
  322. Name: "get_cloud_costs",
  323. Description: "Retrieves cloud cost data.",
  324. }, handleCloudCosts)
  325. mcp_sdk.AddTool(sdkServer, &mcp_sdk.Tool{
  326. Name: "get_efficiency",
  327. Description: "Retrieves resource efficiency metrics with rightsizing recommendations and cost savings analysis. Computes CPU and memory efficiency (usage/request ratio), provides recommended resource requests, and calculates potential cost savings. Optional buffer_multiplier parameter (default: 1.2 for 20% headroom) can be set to values like 1.4 for 40% headroom.",
  328. }, handleEfficiency)
  329. // Create HTTP handler
  330. handler := mcp_sdk.NewStreamableHTTPHandler(func(r *http.Request) *mcp_sdk.Server {
  331. return sdkServer
  332. }, &mcp_sdk.StreamableHTTPOptions{
  333. JSONResponse: true,
  334. })
  335. // Add logging middleware
  336. loggingHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
  337. log.Debugf("MCP HTTP request: %s %s from %s", req.Method, req.URL.Path, req.RemoteAddr)
  338. handler.ServeHTTP(w, req)
  339. })
  340. // Start HTTP server on configured port
  341. port := env.GetMCPHTTPPort()
  342. log.Infof("Starting MCP HTTP server on port %d...", port)
  343. server := &http.Server{
  344. Addr: fmt.Sprintf(":%d", port),
  345. Handler: loggingHandler,
  346. }
  347. // Start server in a goroutine
  348. go func() {
  349. if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
  350. log.Errorf("MCP server failed: %v", err)
  351. }
  352. }()
  353. // Graceful shutdown goroutine
  354. go func() {
  355. <-ctx.Done()
  356. log.Info("Shutting down MCP server...")
  357. shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  358. defer cancel()
  359. if err := server.Shutdown(shutdownCtx); err != nil {
  360. log.Errorf("MCP server shutdown error: %v", err)
  361. } else {
  362. log.Info("MCP server shut down successfully")
  363. }
  364. }()
  365. log.Info("MCP server started successfully")
  366. return nil
  367. }
  368. // Tool argument structures for MCP server
  369. type AllocationArgs struct {
  370. Window string `json:"window"`
  371. Aggregate string `json:"aggregate"`
  372. // Allocation query parameters
  373. Step string `json:"step,omitempty"`
  374. Resolution string `json:"resolution,omitempty"`
  375. Accumulate bool `json:"accumulate,omitempty"`
  376. ShareIdle bool `json:"share_idle,omitempty"`
  377. IncludeIdle bool `json:"include_idle,omitempty"`
  378. IdleByNode bool `json:"idle_by_node,omitempty"`
  379. IncludeProportionalAssetResourceCosts bool `json:"include_proportional_asset_resource_costs,omitempty"`
  380. IncludeAggregatedMetadata bool `json:"include_aggregated_metadata,omitempty"`
  381. ShareLB bool `json:"share_lb,omitempty"`
  382. Filter string `json:"filter,omitempty"`
  383. }
  384. type AssetArgs struct {
  385. Window string `json:"window"`
  386. }
  387. type CloudCostArgs struct {
  388. Window string `json:"window"`
  389. Aggregate string `json:"aggregate"`
  390. // Cloud cost query parameters
  391. Accumulate string `json:"accumulate,omitempty"`
  392. Filter string `json:"filter,omitempty"`
  393. Provider string `json:"provider,omitempty"`
  394. Service string `json:"service,omitempty"`
  395. Category string `json:"category,omitempty"`
  396. Region string `json:"region,omitempty"`
  397. Account string `json:"account,omitempty"`
  398. }
  399. type EfficiencyArgs struct {
  400. Window string `json:"window"` // Time window (e.g., "today", "yesterday", "7d", "lastweek")
  401. Aggregate string `json:"aggregate,omitempty"` // Aggregation level (e.g., "pod", "namespace", "controller")
  402. Filter string `json:"filter,omitempty"` // Filter expression (same as allocation filters)
  403. BufferMultiplier *float64 `json:"buffer_multiplier,omitempty"` // Buffer multiplier for recommendations (default: 1.2 for 20% headroom, e.g., 1.4 for 40%)
  404. Step string `json:"step,omitempty"` // Query step size (e.g., "1h", "6h"); smaller steps reduce peak memory by batching large windows, but may increase query time/requests
  405. }