|
|
@@ -0,0 +1,182 @@
|
|
|
+package allocation
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "sort"
|
|
|
+ "strings"
|
|
|
+
|
|
|
+ "github.com/opencost/opencost/core/pkg/filter"
|
|
|
+ "github.com/opencost/opencost/core/pkg/opencost"
|
|
|
+)
|
|
|
+
|
|
|
+// ErrAutocompleteBadRequest indicates a client error in an autocomplete request.
|
|
|
+var ErrAutocompleteBadRequest = errors.New("autocomplete bad request")
|
|
|
+
|
|
|
+// IsAutocompleteBadRequest reports whether err is a client validation error.
|
|
|
+func IsAutocompleteBadRequest(err error) bool {
|
|
|
+ return errors.Is(err, ErrAutocompleteBadRequest)
|
|
|
+}
|
|
|
+
|
|
|
+const DefaultAutocompleteResultLimit = 100
|
|
|
+const MaxAutocompleteResultLimit = 1000
|
|
|
+
|
|
|
+type AllocationAutocompleteRequest struct {
|
|
|
+ Search string
|
|
|
+ Field string
|
|
|
+ Limit int
|
|
|
+ Window opencost.Window
|
|
|
+ Filter filter.Filter
|
|
|
+ LabelConfig *opencost.LabelConfig
|
|
|
+}
|
|
|
+
|
|
|
+type AllocationAutocompleteResponse struct {
|
|
|
+ Data []string `json:"data"`
|
|
|
+}
|
|
|
+
|
|
|
+type AutocompleteQueryService interface {
|
|
|
+ QueryAllocationAutocomplete(AllocationAutocompleteRequest, context.Context) (*AllocationAutocompleteResponse, error)
|
|
|
+}
|
|
|
+
|
|
|
+func QueryAllocationAutocompleteFromSetRange(asr *opencost.AllocationSetRange, req AllocationAutocompleteRequest) (*AllocationAutocompleteResponse, error) {
|
|
|
+ field, err := validateAutocompleteField(req.Field)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("%w: invalid field: %w", ErrAutocompleteBadRequest, err)
|
|
|
+ }
|
|
|
+
|
|
|
+ limit := req.Limit
|
|
|
+ if limit <= 0 {
|
|
|
+ limit = DefaultAutocompleteResultLimit
|
|
|
+ }
|
|
|
+ if limit > MaxAutocompleteResultLimit {
|
|
|
+ return nil, fmt.Errorf("%w: exceeded maximum autocomplete result limit of %d", ErrAutocompleteBadRequest, MaxAutocompleteResultLimit)
|
|
|
+ }
|
|
|
+
|
|
|
+ var matcher opencost.AllocationMatcher
|
|
|
+ if req.Filter != nil {
|
|
|
+ compiler := opencost.NewAllocationMatchCompiler(req.LabelConfig)
|
|
|
+ matcher, err = compiler.Compile(req.Filter)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("%w: failed to compile filter: %w", ErrAutocompleteBadRequest, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ search := strings.ToLower(req.Search)
|
|
|
+ results := map[string]struct{}{}
|
|
|
+ for _, as := range asr.Allocations {
|
|
|
+ if as == nil {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ for _, alloc := range as.Allocations {
|
|
|
+ if alloc == nil || alloc.Properties == nil {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if matcher != nil && !matcher.Matches(alloc) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ values := allocationAutocompleteValues(alloc.Properties, field)
|
|
|
+ for _, value := range values {
|
|
|
+ if value == "" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if search != "" && !strings.Contains(strings.ToLower(value), search) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ results[value] = struct{}{}
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return &AllocationAutocompleteResponse{Data: uniqueSortedLimited(results, limit)}, nil
|
|
|
+}
|
|
|
+
|
|
|
+func validateAutocompleteField(field string) (string, error) {
|
|
|
+ if field == "" {
|
|
|
+ return "", fmt.Errorf("field is required")
|
|
|
+ }
|
|
|
+
|
|
|
+ f := strings.ToLower(field)
|
|
|
+ switch f {
|
|
|
+ case "cluster", "namespace", "node", "controllerkind", "controllername", "pod", "container", "label", "namespacelabel":
|
|
|
+ return f, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ if strings.HasPrefix(f, "label:") {
|
|
|
+ _, labelKey, _ := strings.Cut(f, ":")
|
|
|
+ return "label:" + labelKey, nil
|
|
|
+ }
|
|
|
+ if strings.HasPrefix(f, "namespacelabel:") {
|
|
|
+ _, labelKey, _ := strings.Cut(f, ":")
|
|
|
+ return "namespacelabel:" + labelKey, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ return "", fmt.Errorf("unrecognized field: %s", field)
|
|
|
+}
|
|
|
+
|
|
|
+func allocationAutocompleteValues(props *opencost.AllocationProperties, field string) []string {
|
|
|
+ switch {
|
|
|
+ case field == "cluster":
|
|
|
+ return []string{props.Cluster}
|
|
|
+ case field == "namespace":
|
|
|
+ return []string{props.Namespace}
|
|
|
+ case field == "node":
|
|
|
+ return []string{props.Node}
|
|
|
+ case field == "controllerkind":
|
|
|
+ return []string{props.ControllerKind}
|
|
|
+ case field == "controllername":
|
|
|
+ return []string{props.Controller}
|
|
|
+ case field == "pod":
|
|
|
+ return []string{props.Pod}
|
|
|
+ case field == "container":
|
|
|
+ return []string{props.Container}
|
|
|
+ case field == "label":
|
|
|
+ return mapKeys(props.Labels)
|
|
|
+ case strings.HasPrefix(field, "label:"):
|
|
|
+ label := strings.TrimPrefix(field, "label:")
|
|
|
+ if v, ok := mapValueFold(props.Labels, label); ok {
|
|
|
+ return []string{v}
|
|
|
+ }
|
|
|
+ case field == "namespacelabel":
|
|
|
+ return mapKeys(props.NamespaceLabels)
|
|
|
+ case strings.HasPrefix(field, "namespacelabel:"):
|
|
|
+ label := strings.TrimPrefix(field, "namespacelabel:")
|
|
|
+ if v, ok := mapValueFold(props.NamespaceLabels, label); ok {
|
|
|
+ return []string{v}
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func mapKeys(values map[string]string) []string {
|
|
|
+ result := make([]string, 0, len(values))
|
|
|
+ for k := range values {
|
|
|
+ result = append(result, k)
|
|
|
+ }
|
|
|
+ return result
|
|
|
+}
|
|
|
+
|
|
|
+func mapValueFold(values map[string]string, key string) (string, bool) {
|
|
|
+ if v, ok := values[key]; ok {
|
|
|
+ return v, true
|
|
|
+ }
|
|
|
+ for k, v := range values {
|
|
|
+ if strings.EqualFold(k, key) {
|
|
|
+ return v, true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return "", false
|
|
|
+}
|
|
|
+
|
|
|
+func uniqueSortedLimited(values map[string]struct{}, limit int) []string {
|
|
|
+ out := make([]string, 0, len(values))
|
|
|
+ for v := range values {
|
|
|
+ out = append(out, v)
|
|
|
+ }
|
|
|
+ sort.Strings(out)
|
|
|
+ if len(out) > limit {
|
|
|
+ return out[:limit]
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|