| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- package cloudcost
- import (
- "encoding/csv"
- "fmt"
- "net/http"
- "strings"
- "github.com/julienschmidt/httprouter"
- filter21 "github.com/opencost/opencost/pkg/filter21"
- "github.com/opencost/opencost/pkg/filter21/cloudcost"
- "github.com/opencost/opencost/pkg/kubecost"
- "github.com/opencost/opencost/pkg/prom"
- "github.com/opencost/opencost/pkg/util/httputil"
- "go.opentelemetry.io/otel"
- )
- const tracerName = "github.com/opencost/ooencost/pkg/cloudcost"
- const (
- csvFormat = "csv"
- )
- // QueryService surfaces endpoints for accessing CloudCost data in raw form or for display in views
- type QueryService struct {
- Querier Querier
- ViewQuerier ViewQuerier
- }
- func NewQueryService(querier Querier, viewQuerier ViewQuerier) *QueryService {
- return &QueryService{
- Querier: querier,
- ViewQuerier: viewQuerier,
- }
- }
- func (s *QueryService) GetCloudCostHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
- // Return valid handler func
- return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
- tracer := otel.Tracer(tracerName)
- ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostHandler")
- defer span.End()
- // If Query Service is nil, always return 501
- if s == nil {
- http.Error(w, "Query Service is nil", http.StatusNotImplemented)
- return
- }
- if s.Querier == nil {
- http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
- return
- }
- request, err := parseCloudCostRequest(r)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- resp, err := s.Querier.Query(*request, ctx)
- if err != nil {
- http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
- return
- }
- _, spanResp := tracer.Start(ctx, "write response")
- w.Header().Set("Content-Type", "application/json")
- protocol.WriteData(w, resp)
- spanResp.End()
- }
- }
- func (s *QueryService) GetCloudCostViewGraphHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
- // Return valid handler func
- return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
- tracer := otel.Tracer(tracerName)
- ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewGraphHandler")
- defer span.End()
- // If Query Service is nil, always return 501
- if s == nil {
- http.Error(w, "Query Service is nil", http.StatusNotImplemented)
- return
- }
- if s.ViewQuerier == nil {
- http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
- return
- }
- request, err := parseCloudCostViewRequest(r)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- resp, err := s.ViewQuerier.QueryViewGraph(*request, ctx)
- if err != nil {
- http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
- return
- }
- _, spanResp := tracer.Start(ctx, "write response")
- w.Header().Set("Content-Type", "application/json")
- protocol.WriteData(w, resp)
- spanResp.End()
- }
- }
- type CloudCostViewTotalsResponse struct {
- NumResults int `json:"numResults"`
- Combined *ViewTableRow `json:"combined"`
- }
- func (s *QueryService) GetCloudCostViewTotalsHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
- // Return valid handler func
- return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
- tracer := otel.Tracer(tracerName)
- ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewTotalsHandler")
- defer span.End()
- // If Query Service is nil, always return 501
- if s == nil {
- http.Error(w, "Query Service is nil", http.StatusNotImplemented)
- return
- }
- if s.ViewQuerier == nil {
- http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
- return
- }
- request, err := parseCloudCostViewRequest(r)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- totals, count, err := s.ViewQuerier.QueryViewTotals(*request, ctx)
- if err != nil {
- http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
- return
- }
- resp := CloudCostViewTotalsResponse{
- NumResults: count,
- Combined: totals,
- }
- _, spanResp := tracer.Start(ctx, "write response")
- w.Header().Set("Content-Type", "application/json")
- protocol.WriteData(w, resp)
- spanResp.End()
- }
- }
- func (s *QueryService) GetCloudCostViewTableHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
- // Return valid handler func
- return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
- tracer := otel.Tracer(tracerName)
- ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewTableHandler")
- defer span.End()
- // If Query Service is nil, always return 501
- if s == nil {
- http.Error(w, "Query Service is nil", http.StatusNotImplemented)
- return
- }
- if s.ViewQuerier == nil {
- http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
- return
- }
- request, err := parseCloudCostViewRequest(r)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- qp := httputil.NewQueryParams(r.URL.Query())
- format := qp.Get("format", "json")
- if strings.HasPrefix(format, csvFormat) {
- w.Header().Set("Content-Type", "text/csv")
- w.Header().Set("Transfer-Encoding", "chunked")
- } else {
- // By default, send JSON
- w.Header().Set("Content-Type", "application/json")
- }
- resp, err := s.ViewQuerier.QueryViewTable(*request, ctx)
- if err != nil {
- http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
- return
- }
- _, spanResp := tracer.Start(ctx, "write response")
- defer spanResp.End()
- if format == csvFormat {
- window := kubecost.NewClosedWindow(request.Start, request.End)
- writeCloudCostViewTableRowsAsCSV(w, resp, window.String())
- return
- }
- w.Header().Set("Content-Type", "application/json")
- protocol.WriteData(w, resp)
- }
- }
- func parseCloudCostRequest(r *http.Request) (*QueryRequest, error) {
- qp := httputil.NewQueryParams(r.URL.Query())
- windowStr := qp.Get("window", "")
- if windowStr == "" {
- return nil, fmt.Errorf("missing require window param")
- }
- window, err := kubecost.ParseWindowUTC(windowStr)
- if err != nil {
- return nil, fmt.Errorf("invalid window parameter: %w", err)
- }
- if window.IsOpen() {
- return nil, fmt.Errorf("invalid window parameter: %s", window.String())
- }
- aggregateByRaw := qp.GetList("aggregate", ",")
- aggregateBy := []string{}
- for _, aggBy := range aggregateByRaw {
- prop, err := ParseCloudCostProperty(aggBy)
- if err != nil {
- return nil, fmt.Errorf("error parsing aggregate by %v", err)
- }
- aggregateBy = append(aggregateBy, prop)
- }
- if len(aggregateBy) == 0 {
- aggregateBy = []string{
- kubecost.CloudCostInvoiceEntityIDProp,
- kubecost.CloudCostAccountIDProp,
- kubecost.CloudCostProviderProp,
- kubecost.CloudCostProviderIDProp,
- kubecost.CloudCostCategoryProp,
- kubecost.CloudCostServiceProp,
- }
- }
- accumulate := kubecost.ParseAccumulate(qp.Get("accumulate", ""))
- var filter filter21.Filter
- filterString := qp.Get("filter", "")
- if filterString != "" {
- parser := cloudcost.NewCloudCostFilterParser()
- filter, err = parser.Parse(filterString)
- if err != nil {
- return nil, fmt.Errorf("Parsing 'filter' parameter: %s", err)
- }
- }
- opts := &QueryRequest{
- Start: *window.Start(),
- End: *window.End(),
- AggregateBy: aggregateBy,
- Accumulate: accumulate,
- Filter: filter,
- }
- return opts, nil
- }
- func ParseCloudCostProperty(text string) (string, error) {
- switch strings.TrimSpace(strings.ToLower(text)) {
- case strings.ToLower(kubecost.CloudCostInvoiceEntityIDProp):
- return kubecost.CloudCostInvoiceEntityIDProp, nil
- case strings.ToLower(kubecost.CloudCostAccountIDProp):
- return kubecost.CloudCostAccountIDProp, nil
- case strings.ToLower(kubecost.CloudCostProviderProp):
- return kubecost.CloudCostProviderProp, nil
- case strings.ToLower(kubecost.CloudCostProviderIDProp):
- return kubecost.CloudCostProviderIDProp, nil
- case strings.ToLower(kubecost.CloudCostCategoryProp):
- return kubecost.CloudCostCategoryProp, nil
- case strings.ToLower(kubecost.CloudCostServiceProp):
- return kubecost.CloudCostServiceProp, nil
- }
- if strings.HasPrefix(text, "label:") {
- label := prom.SanitizeLabelName(strings.TrimSpace(strings.TrimPrefix(text, "label:")))
- return fmt.Sprintf("label:%s", label), nil
- }
- return "", fmt.Errorf("invalid cloud cost property: %s", text)
- }
- func parseCloudCostViewRequest(r *http.Request) (*ViewQueryRequest, error) {
- qr, err := parseCloudCostRequest(r)
- if err != nil {
- return nil, err
- }
- qp := httputil.NewQueryParams(r.URL.Query())
- // parse cost metric
- costMetricName, err := kubecost.ParseCostMetricName(qp.Get("costMetric", string(kubecost.CostMetricAmortizedNetCost)))
- if err != nil {
- return nil, fmt.Errorf("error parsing 'costMetric': %w", err)
- }
- limit := qp.GetInt("limit", 0)
- offset := qp.GetInt("offset", 0)
- // parse order
- order, err := ParseSortDirection(qp.Get("sortByOrder", "desc"))
- if err != nil {
- return nil, fmt.Errorf("error parsing 'sortByOrder: %w", err)
- }
- sortColumn, err := ParseSortField(qp.Get("sortBy", "cost"))
- if err != nil {
- return nil, fmt.Errorf("error parsing 'sortBy': %w", err)
- }
- return &ViewQueryRequest{
- QueryRequest: *qr,
- CostMetricName: costMetricName,
- ChartItemsLength: DefaultChartItemsLength,
- Limit: limit,
- Offset: offset,
- SortDirection: order,
- SortColumn: sortColumn,
- }, nil
- }
- // CloudCostViewTableRowsToCSV takes the csv writer and writes the ViewTableRows into the writer.
- func CloudCostViewTableRowsToCSV(writer *csv.Writer, ctr ViewTableRows, window string) error {
- defer writer.Flush()
- // Write the column headers
- headers := []string{
- "Name",
- "K8s Utilization",
- "Total",
- "Window",
- }
- err := writer.Write(headers)
- if err != nil {
- return fmt.Errorf("CloudCostViewTableRowsToCSV: failed to convert ViewTableRows to csv with error: %w", err)
- }
- // Write one row per entry in the ViewTableRows
- for _, row := range ctr {
- err = writer.Write([]string{
- row.Name,
- fmt.Sprintf("%.3f", row.KubernetesPercent),
- fmt.Sprintf("%.3f", row.Cost),
- window,
- })
- if err != nil {
- return fmt.Errorf("CloudCostViewTableRowsToCSV: failed to convert ViewTableRows to csv with error: %w", err)
- }
- }
- return nil
- }
- func writeCloudCostViewTableRowsAsCSV(w http.ResponseWriter, ctr ViewTableRows, window string) {
- writer := csv.NewWriter(w)
- err := CloudCostViewTableRowsToCSV(writer, ctr, window)
- if err != nil {
- protocol.WriteError(w, protocol.InternalServerError(err.Error()))
- return
- }
- }
|