Browse Source

Allocation autocomplete: Support label aliases (#4045)

Signed-off-by: Bianca Burtoiu <bianca.burtoiu@ibm.com>
Bianca Burtoiu 6 days ago
parent
commit
62adb62256

+ 17 - 0
core/pkg/autocomplete/allocation/alias.go

@@ -0,0 +1,17 @@
+package allocation
+
+import (
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+// ResolveAliasLabelKey resolves an alias field (department, environment, owner,
+// product, team) to its configured label key.
+func ResolveAliasLabelKey(field string, lc *opencost.LabelConfig) (string, bool) {
+	if lc == nil {
+		lc = opencost.NewLabelConfig()
+	}
+
+	labelConfigMap := lc.Map()
+	value, ok := labelConfigMap[field+"_label"]
+	return value, ok
+}

+ 34 - 0
core/pkg/autocomplete/allocation/allocation_test.go

@@ -21,6 +21,11 @@ func TestValidateField(t *testing.T) {
 		{"label", "label", false},
 		{"label:App", "label:App", false},
 		{"namespacelabel:Team", "namespacelabel:Team", false},
+		{"department", "department", false},
+		{"environment", "environment", false},
+		{"owner", "owner", false},
+		{"product", "product", false},
+		{"team", "team", false},
 		{"", "", true},
 		{"bad", "", true},
 	}
@@ -88,6 +93,11 @@ func TestRouteField(t *testing.T) {
 		{"label:App", RouteLabelValue, "App"},
 		{"namespacelabel", RouteNamespaceLabelKeys, ""},
 		{"cluster", RouteDefault, ""},
+		{"department", RouteAlias, ""},
+		{"environment", RouteAlias, ""},
+		{"owner", RouteAlias, ""},
+		{"product", RouteAlias, ""},
+		{"team", RouteAlias, ""},
 	}
 	for _, tt := range tests {
 		route, key, err := RouteField(tt.field)
@@ -96,3 +106,27 @@ func TestRouteField(t *testing.T) {
 		}
 	}
 }
+
+func TestResolveAliasLabelKey(t *testing.T) {
+	tests := []struct {
+		field       string
+		labelConfig *opencost.LabelConfig
+		expected    string
+	}{
+		{"department", nil, "department"},
+		{"environment", nil, "env"},
+		{"owner", nil, "owner"},
+		{"product", nil, "app"},
+		{"team", nil, "team"},
+		{"department", &opencost.LabelConfig{DepartmentLabel: "dept"}, "dept"},
+		{"does-not-exist", &opencost.LabelConfig{DepartmentLabel: "dept"}, ""},
+		{"owner", &opencost.LabelConfig{OwnerLabel: "alpha.test.io/owner-name"}, "alpha.test.io/owner-name"},
+	}
+
+	for _, tt := range tests {
+		actual, _ := ResolveAliasLabelKey(tt.field, tt.labelConfig)
+		if actual != tt.expected {
+			t.Fatalf("TestResolveAliasLabelKey(%q, %v) = %q; want %q", tt.field, tt.labelConfig, actual, tt.expected)
+		}
+	}
+}

+ 5 - 0
core/pkg/autocomplete/allocation/route.go

@@ -11,6 +11,7 @@ const (
 	RouteLabelValue
 	RouteNamespaceLabelKeys
 	RouteNamespaceLabelValue
+	RouteAlias
 )
 
 // RouteField maps a normalized field to a query route and label key when applicable.
@@ -31,5 +32,9 @@ func RouteField(field string) (Route, string, error) {
 			return RouteNamespaceLabelValue, key, nil
 		}
 	}
+	switch field {
+	case "department", "environment", "owner", "product", "team":
+		return RouteAlias, "", nil
+	}
 	return RouteDefault, "", nil
 }

+ 2 - 1
core/pkg/autocomplete/allocation/validate.go

@@ -15,7 +15,8 @@ func ValidateField(field string) (string, error) {
 
 	f := strings.ToLower(field)
 	switch f {
-	case "account", "cluster", "namespace", "node", "controllerkind", "controllername", "pod", "container", "label", "namespacelabel":
+	case "account", "cluster", "namespace", "node", "controllerkind", "controllername", "pod", "container", "label", "namespacelabel",
+		"department", "environment", "owner", "product", "team":
 		return f, nil
 	}
 

+ 6 - 0
pkg/allocation/autocompletequeryservice.go

@@ -17,6 +17,12 @@ func QueryAllocationAutocompleteFromSetRange(asr *opencost.AllocationSetRange, r
 		return nil, err
 	}
 
+	if route, _, _ := coreallocation.RouteField(field); route == coreallocation.RouteAlias {
+		if key, ok := coreallocation.ResolveAliasLabelKey(field, req.LabelConfig); ok {
+			field = "label:" + key
+		}
+	}
+
 	var matcher opencost.AllocationMatcher
 	if autocomplete.HasFilter(req.Filter) {
 		compiler := opencost.NewAllocationMatchCompiler(req.LabelConfig)

+ 24 - 2
pkg/allocation/autocompletequeryservice_test.go

@@ -20,7 +20,7 @@ func TestQueryAllocationAutocompleteFromSetRange(t *testing.T) {
 		ControllerKind:  "deployment",
 		Controller:      "deploy-a",
 		Node:            "node-a",
-		Labels:          map[string]string{"Team": "platform", "app": "api"},
+		Labels:          map[string]string{"Team": "platform", "app": "api", "department": "engineering"},
 		NamespaceLabels: map[string]string{"owner": "sre"},
 	}))
 	as.Set(opencost.NewMockUnitAllocation("a2", start, 24*time.Hour, &opencost.AllocationProperties{
@@ -47,7 +47,7 @@ func TestQueryAllocationAutocompleteFromSetRange(t *testing.T) {
 	if err != nil {
 		t.Fatalf("unexpected error: %v", err)
 	}
-	if len(resp.Data) != 2 || resp.Data[0] != "Team" || resp.Data[1] != "app" {
+	if len(resp.Data) != 3 || resp.Data[0] != "Team" || resp.Data[1] != "app" || resp.Data[2] != "department" {
 		t.Fatalf("unexpected label autocomplete response: %+v", resp.Data)
 	}
 
@@ -85,6 +85,28 @@ func TestQueryAllocationAutocompleteFromSetRange(t *testing.T) {
 		t.Fatalf("expected empty account autocomplete response, got %+v", accountResp.Data)
 	}
 
+	departmentResp, err := QueryAllocationAutocompleteFromSetRange(asr, autocomplete.Request{
+		Field:  "department",
+		Window: window,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error for department field: %v", err)
+	}
+	if len(departmentResp.Data) != 1 || departmentResp.Data[0] != "engineering" {
+		t.Fatalf("expected one autocomplete response for department, got %+v", departmentResp.Data)
+	}
+
+	productResp, err := QueryAllocationAutocompleteFromSetRange(asr, autocomplete.Request{
+		Field:  "product", // validate config driven resolution (product -> label:app)
+		Window: window,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error for product field: %v", err)
+	}
+	if len(productResp.Data) != 2 || productResp.Data[0] != "api" || productResp.Data[1] != "db" {
+		t.Fatalf("expected two autocomplete responses for product, got %+v", productResp.Data)
+	}
+
 	_, err = QueryAllocationAutocompleteFromSetRange(asr, autocomplete.Request{
 		Field:  "namespace",
 		Limit:  autocomplete.MaxResultLimit + 1,