queryservice.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. package cloudcost
  2. import (
  3. "encoding/csv"
  4. "fmt"
  5. "net/http"
  6. "strings"
  7. "github.com/julienschmidt/httprouter"
  8. filter21 "github.com/opencost/opencost/pkg/filter21"
  9. "github.com/opencost/opencost/pkg/filter21/cloudcost"
  10. "github.com/opencost/opencost/pkg/kubecost"
  11. "github.com/opencost/opencost/pkg/prom"
  12. "github.com/opencost/opencost/pkg/util/httputil"
  13. "go.opentelemetry.io/otel"
  14. )
  15. const tracerName = "github.com/opencost/ooencost/pkg/cloudcost"
  16. const (
  17. csvFormat = "csv"
  18. )
  19. // QueryService surfaces endpoints for accessing CloudCost data in raw form or for display in views
  20. type QueryService struct {
  21. Querier Querier
  22. ViewQuerier ViewQuerier
  23. }
  24. func NewQueryService(querier Querier, viewQuerier ViewQuerier) *QueryService {
  25. return &QueryService{
  26. Querier: querier,
  27. ViewQuerier: viewQuerier,
  28. }
  29. }
  30. func (s *QueryService) GetCloudCostHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  31. // Return valid handler func
  32. return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  33. tracer := otel.Tracer(tracerName)
  34. ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostHandler")
  35. defer span.End()
  36. // If Query Service is nil, always return 501
  37. if s == nil {
  38. http.Error(w, "Query Service is nil", http.StatusNotImplemented)
  39. return
  40. }
  41. if s.Querier == nil {
  42. http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
  43. return
  44. }
  45. request, err := parseCloudCostRequest(r)
  46. if err != nil {
  47. http.Error(w, err.Error(), http.StatusBadRequest)
  48. return
  49. }
  50. resp, err := s.Querier.Query(*request, ctx)
  51. if err != nil {
  52. http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
  53. return
  54. }
  55. _, spanResp := tracer.Start(ctx, "write response")
  56. w.Header().Set("Content-Type", "application/json")
  57. protocol.WriteData(w, resp)
  58. spanResp.End()
  59. }
  60. }
  61. func (s *QueryService) GetCloudCostViewGraphHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  62. // Return valid handler func
  63. return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  64. tracer := otel.Tracer(tracerName)
  65. ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewGraphHandler")
  66. defer span.End()
  67. // If Query Service is nil, always return 501
  68. if s == nil {
  69. http.Error(w, "Query Service is nil", http.StatusNotImplemented)
  70. return
  71. }
  72. if s.ViewQuerier == nil {
  73. http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
  74. return
  75. }
  76. request, err := parseCloudCostViewRequest(r)
  77. if err != nil {
  78. http.Error(w, err.Error(), http.StatusBadRequest)
  79. return
  80. }
  81. resp, err := s.ViewQuerier.QueryViewGraph(*request, ctx)
  82. if err != nil {
  83. http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
  84. return
  85. }
  86. _, spanResp := tracer.Start(ctx, "write response")
  87. w.Header().Set("Content-Type", "application/json")
  88. protocol.WriteData(w, resp)
  89. spanResp.End()
  90. }
  91. }
  92. type CloudCostViewTotalsResponse struct {
  93. NumResults int `json:"numResults"`
  94. Combined *ViewTableRow `json:"combined"`
  95. }
  96. func (s *QueryService) GetCloudCostViewTotalsHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  97. // Return valid handler func
  98. return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  99. tracer := otel.Tracer(tracerName)
  100. ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewTotalsHandler")
  101. defer span.End()
  102. // If Query Service is nil, always return 501
  103. if s == nil {
  104. http.Error(w, "Query Service is nil", http.StatusNotImplemented)
  105. return
  106. }
  107. if s.ViewQuerier == nil {
  108. http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
  109. return
  110. }
  111. request, err := parseCloudCostViewRequest(r)
  112. if err != nil {
  113. http.Error(w, err.Error(), http.StatusBadRequest)
  114. return
  115. }
  116. totals, count, err := s.ViewQuerier.QueryViewTotals(*request, ctx)
  117. if err != nil {
  118. http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
  119. return
  120. }
  121. resp := CloudCostViewTotalsResponse{
  122. NumResults: count,
  123. Combined: totals,
  124. }
  125. _, spanResp := tracer.Start(ctx, "write response")
  126. w.Header().Set("Content-Type", "application/json")
  127. protocol.WriteData(w, resp)
  128. spanResp.End()
  129. }
  130. }
  131. func (s *QueryService) GetCloudCostViewTableHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  132. // Return valid handler func
  133. return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  134. tracer := otel.Tracer(tracerName)
  135. ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewTableHandler")
  136. defer span.End()
  137. // If Query Service is nil, always return 501
  138. if s == nil {
  139. http.Error(w, "Query Service is nil", http.StatusNotImplemented)
  140. return
  141. }
  142. if s.ViewQuerier == nil {
  143. http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
  144. return
  145. }
  146. request, err := parseCloudCostViewRequest(r)
  147. if err != nil {
  148. http.Error(w, err.Error(), http.StatusBadRequest)
  149. return
  150. }
  151. qp := httputil.NewQueryParams(r.URL.Query())
  152. format := qp.Get("format", "json")
  153. if strings.HasPrefix(format, csvFormat) {
  154. w.Header().Set("Content-Type", "text/csv")
  155. w.Header().Set("Transfer-Encoding", "chunked")
  156. } else {
  157. // By default, send JSON
  158. w.Header().Set("Content-Type", "application/json")
  159. }
  160. resp, err := s.ViewQuerier.QueryViewTable(*request, ctx)
  161. if err != nil {
  162. http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
  163. return
  164. }
  165. _, spanResp := tracer.Start(ctx, "write response")
  166. defer spanResp.End()
  167. if format == csvFormat {
  168. window := kubecost.NewClosedWindow(request.Start, request.End)
  169. writeCloudCostViewTableRowsAsCSV(w, resp, window.String())
  170. return
  171. }
  172. w.Header().Set("Content-Type", "application/json")
  173. protocol.WriteData(w, resp)
  174. }
  175. }
  176. func parseCloudCostRequest(r *http.Request) (*QueryRequest, error) {
  177. qp := httputil.NewQueryParams(r.URL.Query())
  178. windowStr := qp.Get("window", "")
  179. if windowStr == "" {
  180. return nil, fmt.Errorf("missing require window param")
  181. }
  182. window, err := kubecost.ParseWindowUTC(windowStr)
  183. if err != nil {
  184. return nil, fmt.Errorf("invalid window parameter: %w", err)
  185. }
  186. if window.IsOpen() {
  187. return nil, fmt.Errorf("invalid window parameter: %s", window.String())
  188. }
  189. aggregateByRaw := qp.GetList("aggregate", ",")
  190. aggregateBy := []string{}
  191. for _, aggBy := range aggregateByRaw {
  192. prop, err := ParseCloudCostProperty(aggBy)
  193. if err != nil {
  194. return nil, fmt.Errorf("error parsing aggregate by %v", err)
  195. }
  196. aggregateBy = append(aggregateBy, prop)
  197. }
  198. if len(aggregateBy) == 0 {
  199. aggregateBy = []string{
  200. kubecost.CloudCostInvoiceEntityIDProp,
  201. kubecost.CloudCostAccountIDProp,
  202. kubecost.CloudCostProviderProp,
  203. kubecost.CloudCostProviderIDProp,
  204. kubecost.CloudCostCategoryProp,
  205. kubecost.CloudCostServiceProp,
  206. }
  207. }
  208. accumulate := kubecost.ParseAccumulate(qp.Get("accumulate", ""))
  209. var filter filter21.Filter
  210. filterString := qp.Get("filter", "")
  211. if filterString != "" {
  212. parser := cloudcost.NewCloudCostFilterParser()
  213. filter, err = parser.Parse(filterString)
  214. if err != nil {
  215. return nil, fmt.Errorf("Parsing 'filter' parameter: %s", err)
  216. }
  217. }
  218. opts := &QueryRequest{
  219. Start: *window.Start(),
  220. End: *window.End(),
  221. AggregateBy: aggregateBy,
  222. Accumulate: accumulate,
  223. Filter: filter,
  224. }
  225. return opts, nil
  226. }
  227. func ParseCloudCostProperty(text string) (string, error) {
  228. switch strings.TrimSpace(strings.ToLower(text)) {
  229. case strings.ToLower(kubecost.CloudCostInvoiceEntityIDProp):
  230. return kubecost.CloudCostInvoiceEntityIDProp, nil
  231. case strings.ToLower(kubecost.CloudCostAccountIDProp):
  232. return kubecost.CloudCostAccountIDProp, nil
  233. case strings.ToLower(kubecost.CloudCostProviderProp):
  234. return kubecost.CloudCostProviderProp, nil
  235. case strings.ToLower(kubecost.CloudCostProviderIDProp):
  236. return kubecost.CloudCostProviderIDProp, nil
  237. case strings.ToLower(kubecost.CloudCostCategoryProp):
  238. return kubecost.CloudCostCategoryProp, nil
  239. case strings.ToLower(kubecost.CloudCostServiceProp):
  240. return kubecost.CloudCostServiceProp, nil
  241. }
  242. if strings.HasPrefix(text, "label:") {
  243. label := prom.SanitizeLabelName(strings.TrimSpace(strings.TrimPrefix(text, "label:")))
  244. return fmt.Sprintf("label:%s", label), nil
  245. }
  246. return "", fmt.Errorf("invalid cloud cost property: %s", text)
  247. }
  248. func parseCloudCostViewRequest(r *http.Request) (*ViewQueryRequest, error) {
  249. qr, err := parseCloudCostRequest(r)
  250. if err != nil {
  251. return nil, err
  252. }
  253. qp := httputil.NewQueryParams(r.URL.Query())
  254. // parse cost metric
  255. costMetricName, err := kubecost.ParseCostMetricName(qp.Get("costMetric", string(kubecost.CostMetricAmortizedNetCost)))
  256. if err != nil {
  257. return nil, fmt.Errorf("error parsing 'costMetric': %w", err)
  258. }
  259. limit := qp.GetInt("limit", 0)
  260. offset := qp.GetInt("offset", 0)
  261. // parse order
  262. order, err := ParseSortDirection(qp.Get("sortByOrder", "desc"))
  263. if err != nil {
  264. return nil, fmt.Errorf("error parsing 'sortByOrder: %w", err)
  265. }
  266. sortColumn, err := ParseSortField(qp.Get("sortBy", "cost"))
  267. if err != nil {
  268. return nil, fmt.Errorf("error parsing 'sortBy': %w", err)
  269. }
  270. return &ViewQueryRequest{
  271. QueryRequest: *qr,
  272. CostMetricName: costMetricName,
  273. ChartItemsLength: DefaultChartItemsLength,
  274. Limit: limit,
  275. Offset: offset,
  276. SortDirection: order,
  277. SortColumn: sortColumn,
  278. }, nil
  279. }
  280. // CloudCostViewTableRowsToCSV takes the csv writer and writes the ViewTableRows into the writer.
  281. func CloudCostViewTableRowsToCSV(writer *csv.Writer, ctr ViewTableRows, window string) error {
  282. defer writer.Flush()
  283. // Write the column headers
  284. headers := []string{
  285. "Name",
  286. "K8s Utilization",
  287. "Total",
  288. "Window",
  289. }
  290. err := writer.Write(headers)
  291. if err != nil {
  292. return fmt.Errorf("CloudCostViewTableRowsToCSV: failed to convert ViewTableRows to csv with error: %w", err)
  293. }
  294. // Write one row per entry in the ViewTableRows
  295. for _, row := range ctr {
  296. err = writer.Write([]string{
  297. row.Name,
  298. fmt.Sprintf("%.3f", row.KubernetesPercent),
  299. fmt.Sprintf("%.3f", row.Cost),
  300. window,
  301. })
  302. if err != nil {
  303. return fmt.Errorf("CloudCostViewTableRowsToCSV: failed to convert ViewTableRows to csv with error: %w", err)
  304. }
  305. }
  306. return nil
  307. }
  308. func writeCloudCostViewTableRowsAsCSV(w http.ResponseWriter, ctr ViewTableRows, window string) {
  309. writer := csv.NewWriter(w)
  310. err := CloudCostViewTableRowsToCSV(writer, ctr, window)
  311. if err != nil {
  312. protocol.WriteError(w, protocol.InternalServerError(err.Error()))
  313. return
  314. }
  315. }