filterutil.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. package filterutil
  2. import (
  3. "strings"
  4. "github.com/opencost/opencost/pkg/costmodel/clusters"
  5. "github.com/opencost/opencost/pkg/kubecost"
  6. "github.com/opencost/opencost/pkg/log"
  7. "github.com/opencost/opencost/pkg/prom"
  8. "github.com/opencost/opencost/pkg/util/typeutil"
  9. filter "github.com/opencost/opencost/pkg/filter21"
  10. afilter "github.com/opencost/opencost/pkg/filter21/allocation"
  11. "github.com/opencost/opencost/pkg/filter21/ast"
  12. // cloudfilter "github.com/opencost/opencost/pkg/filter/cloud"
  13. )
  14. // ============================================================================
  15. // This file contains:
  16. // Parsing (HTTP query params -> v2.1 filter) for V1 of query param filters
  17. //
  18. // e.g. "filterNamespaces=ku&filterControllers=deployment:kc"
  19. // ============================================================================
  20. // This is somewhat of a fancy solution, but allows us to "register" DefaultFieldByName funcs
  21. // funcs by Field type.
  22. var defaultFieldByType = map[string]any{
  23. // typeutil.TypeOf[cloudfilter.CloudAggregationField](): cloudfilter.DefaultFieldByName,
  24. typeutil.TypeOf[afilter.AllocationField](): afilter.DefaultFieldByName,
  25. }
  26. // DefaultFieldByName looks up a specific T field instance by name and returns the default
  27. // ast.Field value for that type.
  28. func DefaultFieldByName[T ~string](field T) *ast.Field {
  29. lookup, ok := defaultFieldByType[typeutil.TypeOf[T]()]
  30. if !ok {
  31. log.Errorf("Failed to get default field lookup for: %s", typeutil.TypeOf[T]())
  32. return nil
  33. }
  34. defaultLookup, ok := lookup.(func(T) *ast.Field)
  35. if !ok {
  36. log.Errorf("Failed to cast default field lookup for: %s", typeutil.TypeOf[T]())
  37. return nil
  38. }
  39. return defaultLookup(field)
  40. }
  41. const (
  42. ParamFilterClusters = "filterClusters"
  43. ParamFilterNodes = "filterNodes"
  44. ParamFilterNamespaces = "filterNamespaces"
  45. ParamFilterControllerKinds = "filterControllerKinds"
  46. ParamFilterControllers = "filterControllers"
  47. ParamFilterPods = "filterPods"
  48. ParamFilterContainers = "filterContainers"
  49. ParamFilterDepartments = "filterDepartments"
  50. ParamFilterEnvironments = "filterEnvironments"
  51. ParamFilterOwners = "filterOwners"
  52. ParamFilterProducts = "filterProducts"
  53. ParamFilterTeams = "filterTeams"
  54. ParamFilterAnnotations = "filterAnnotations"
  55. ParamFilterLabels = "filterLabels"
  56. ParamFilterServices = "filterServices"
  57. )
  58. // AllocationPropToV1FilterParamKey maps allocation string property
  59. // representations to v1 filter param keys for legacy filter config support
  60. // (e.g. reports). Example mapping: "cluster" -> "filterClusters"
  61. var AllocationPropToV1FilterParamKey = map[string]string{
  62. kubecost.AllocationClusterProp: ParamFilterClusters,
  63. kubecost.AllocationNodeProp: ParamFilterNodes,
  64. kubecost.AllocationNamespaceProp: ParamFilterNamespaces,
  65. kubecost.AllocationControllerProp: ParamFilterControllers,
  66. kubecost.AllocationControllerKindProp: ParamFilterControllerKinds,
  67. kubecost.AllocationPodProp: ParamFilterPods,
  68. kubecost.AllocationLabelProp: ParamFilterLabels,
  69. kubecost.AllocationServiceProp: ParamFilterServices,
  70. kubecost.AllocationDepartmentProp: ParamFilterDepartments,
  71. kubecost.AllocationEnvironmentProp: ParamFilterEnvironments,
  72. kubecost.AllocationOwnerProp: ParamFilterOwners,
  73. kubecost.AllocationProductProp: ParamFilterProducts,
  74. kubecost.AllocationTeamProp: ParamFilterTeams,
  75. }
  76. // AllHTTPParamKeys returns all HTTP GET parameters used for v1 filters. It is
  77. // intended to help validate HTTP queries in handlers to help avoid e.g.
  78. // spelling errors.
  79. func AllHTTPParamKeys() []string {
  80. return []string{
  81. ParamFilterClusters,
  82. ParamFilterNodes,
  83. ParamFilterNamespaces,
  84. ParamFilterControllerKinds,
  85. ParamFilterControllers,
  86. ParamFilterPods,
  87. ParamFilterContainers,
  88. ParamFilterDepartments,
  89. ParamFilterEnvironments,
  90. ParamFilterOwners,
  91. ParamFilterProducts,
  92. ParamFilterTeams,
  93. ParamFilterAnnotations,
  94. ParamFilterLabels,
  95. ParamFilterServices,
  96. }
  97. }
  98. // AllocationFilterFromParamsV1 takes a set of HTTP query parameters and
  99. // converts them to an AllocationFilter, which is a structured in-Go
  100. // representation of a set of filters.
  101. //
  102. // The HTTP query parameters are the "v1" filters attached to the Allocation
  103. // API: "filterNamespaces=", "filterNodes=", etc.
  104. //
  105. // It takes an optional LabelConfig, which if provided enables "label-mapped"
  106. // filters like "filterDepartments".
  107. //
  108. // It takes an optional ClusterMap, which if provided enables cluster name
  109. // filtering. This turns all `filterClusters=foo` arguments into the equivalent
  110. // of `clusterID = "foo" OR clusterName = "foo"`.
  111. func AllocationFilterFromParamsV1(
  112. params AllocationFilterV1,
  113. labelConfig *kubecost.LabelConfig,
  114. clusterMap clusters.ClusterMap,
  115. ) filter.Filter {
  116. var filterOps []ast.FilterNode
  117. // ClusterMap does not provide a cluster name -> cluster ID mapping in the
  118. // interface, probably because there could be multiple IDs with the same
  119. // name. However, V1 filter logic demands that the parameters to
  120. // filterClusters= be checked against both cluster ID AND cluster name.
  121. //
  122. // To support expected filterClusters= behavior, we construct a mapping
  123. // of cluster name -> cluster IDs (could be multiple IDs for the same name)
  124. // so that we can create AllocationFilters that use only ClusterIDEquals.
  125. //
  126. //
  127. // AllocationFilter intentionally does not support cluster name filters
  128. // because those should be considered presentation-layer only.
  129. clusterNameToIDs := map[string][]string{}
  130. if clusterMap != nil {
  131. cMap := clusterMap.AsMap()
  132. for _, info := range cMap {
  133. if info == nil {
  134. continue
  135. }
  136. if _, ok := clusterNameToIDs[info.Name]; ok {
  137. clusterNameToIDs[info.Name] = append(clusterNameToIDs[info.Name], info.ID)
  138. } else {
  139. clusterNameToIDs[info.Name] = []string{info.ID}
  140. }
  141. }
  142. }
  143. // The proliferation of > 0 guards in the function is to avoid constructing
  144. // empty filter structs. While it is functionally equivalent to add empty
  145. // filter structs (they evaluate to true always) there could be overhead
  146. // when calling Matches() repeatedly for no purpose.
  147. if len(params.Clusters) > 0 {
  148. var ops []ast.FilterNode
  149. // filter my cluster identifier
  150. ops = push(ops, filterV1SingleValueFromList(params.Clusters, afilter.FieldClusterID))
  151. for _, rawFilterValue := range params.Clusters {
  152. clusterNameFilter, wildcard := parseWildcardEnd(rawFilterValue)
  153. clusterIDsToFilter := []string{}
  154. for clusterName := range clusterNameToIDs {
  155. if wildcard && strings.HasPrefix(clusterName, clusterNameFilter) {
  156. clusterIDsToFilter = append(clusterIDsToFilter, clusterNameToIDs[clusterName]...)
  157. } else if !wildcard && clusterName == clusterNameFilter {
  158. clusterIDsToFilter = append(clusterIDsToFilter, clusterNameToIDs[clusterName]...)
  159. }
  160. }
  161. for _, clusterID := range clusterIDsToFilter {
  162. ops = append(ops, &ast.EqualOp{
  163. Left: ast.Identifier{
  164. Field: afilter.DefaultFieldByName(afilter.FieldClusterID),
  165. Key: "",
  166. },
  167. Right: clusterID,
  168. })
  169. }
  170. }
  171. //
  172. clustersOp := opsToOr(ops)
  173. filterOps = push(filterOps, clustersOp)
  174. }
  175. if len(params.Nodes) > 0 {
  176. filterOps = push(filterOps, filterV1SingleValueFromList(params.Nodes, afilter.FieldNode))
  177. }
  178. if len(params.Namespaces) > 0 {
  179. filterOps = push(filterOps, filterV1SingleValueFromList(params.Namespaces, afilter.FieldNamespace))
  180. }
  181. if len(params.ControllerKinds) > 0 {
  182. filterOps = push(filterOps, filterV1SingleValueFromList(params.ControllerKinds, afilter.FieldControllerKind))
  183. }
  184. // filterControllers= accepts controllerkind:controllername filters, e.g.
  185. // "deployment:kubecost-cost-analyzer"
  186. //
  187. // Thus, we have to make a custom OR filter for this condition.
  188. if len(params.Controllers) > 0 {
  189. var ops []ast.FilterNode
  190. for _, rawFilterValue := range params.Controllers {
  191. split := strings.Split(rawFilterValue, ":")
  192. if len(split) == 1 {
  193. filterValue, wildcard := parseWildcardEnd(split[0])
  194. subFilter := toEqualOp(afilter.FieldControllerName, "", filterValue, wildcard)
  195. ops = append(ops, subFilter)
  196. } else if len(split) == 2 {
  197. kindFilterVal := split[0]
  198. nameFilterVal, wildcard := parseWildcardEnd(split[1])
  199. kindFilter := toEqualOp(afilter.FieldControllerKind, "", kindFilterVal, false)
  200. nameFilter := toEqualOp(afilter.FieldControllerName, "", nameFilterVal, wildcard)
  201. // The controller name AND the controller kind must match
  202. ops = append(ops, &ast.AndOp{
  203. Operands: []ast.FilterNode{
  204. kindFilter,
  205. nameFilter,
  206. },
  207. })
  208. } else {
  209. log.Warnf("illegal filter for controller: %s", rawFilterValue)
  210. }
  211. }
  212. controllersOp := opsToOr(ops)
  213. filterOps = push(filterOps, controllersOp)
  214. }
  215. if len(params.Pods) > 0 {
  216. filterOps = push(filterOps, filterV1SingleValueFromList(params.Pods, afilter.FieldPod))
  217. }
  218. if len(params.Containers) > 0 {
  219. filterOps = push(filterOps, filterV1SingleValueFromList(params.Containers, afilter.FieldContainer))
  220. }
  221. // Label-mapped queries require a label config to be present.
  222. if labelConfig != nil {
  223. if len(params.Departments) > 0 {
  224. filterOps = push(filterOps, filterV1LabelAliasMappedFromList(params.Departments, labelConfig.DepartmentLabel))
  225. }
  226. if len(params.Environments) > 0 {
  227. filterOps = push(filterOps, filterV1LabelAliasMappedFromList(params.Environments, labelConfig.EnvironmentLabel))
  228. }
  229. if len(params.Owners) > 0 {
  230. filterOps = push(filterOps, filterV1LabelAliasMappedFromList(params.Owners, labelConfig.OwnerLabel))
  231. }
  232. if len(params.Products) > 0 {
  233. filterOps = push(filterOps, filterV1LabelAliasMappedFromList(params.Products, labelConfig.ProductLabel))
  234. }
  235. if len(params.Teams) > 0 {
  236. filterOps = push(filterOps, filterV1LabelAliasMappedFromList(params.Teams, labelConfig.TeamLabel))
  237. }
  238. } else {
  239. log.Debugf("No label config is available. Not creating filters for label-mapped 'fields'.")
  240. }
  241. if len(params.Annotations) > 0 {
  242. filterOps = push(filterOps, filterV1DoubleValueFromList(params.Annotations, afilter.FieldAnnotation))
  243. }
  244. if len(params.Labels) > 0 {
  245. filterOps = push(filterOps, filterV1DoubleValueFromList(params.Labels, afilter.FieldLabel))
  246. }
  247. if len(params.Services) > 0 {
  248. var ops []ast.FilterNode
  249. // filterServices= is the only filter that uses the "contains" operator.
  250. for _, filterValue := range params.Services {
  251. // TODO: wildcard support
  252. filterValue, wildcard := parseWildcardEnd(filterValue)
  253. subFilter := toContainsOp(afilter.FieldServices, "", filterValue, wildcard)
  254. ops = append(ops, subFilter)
  255. }
  256. serviceOps := opsToOr(ops)
  257. filterOps = push(filterOps, serviceOps)
  258. }
  259. andFilter := opsToAnd(filterOps)
  260. if andFilter == nil {
  261. return &ast.VoidOp{} // no filter
  262. }
  263. return andFilter
  264. }
  265. // filterV1SingleValueFromList creates an OR of equality filters for a given
  266. // filter field.
  267. //
  268. // The v1 query language (e.g. "filterNamespaces=XYZ,ABC") uses OR within
  269. // a field (e.g. namespace = XYZ OR namespace = ABC)
  270. func filterV1SingleValueFromList[T ~string](rawFilterValues []string, filterField T) ast.FilterNode {
  271. var ops []ast.FilterNode
  272. for _, filterValue := range rawFilterValues {
  273. filterValue = strings.TrimSpace(filterValue)
  274. filterValue, wildcard := parseWildcardEnd(filterValue)
  275. subFilter := toEqualOp(filterField, "", filterValue, wildcard)
  276. ops = append(ops, subFilter)
  277. }
  278. return opsToOr(ops)
  279. }
  280. // filterV1LabelAliasMappedFromList is like filterV1SingleValueFromList but is
  281. // explicitly for labels and annotations because "label-mapped" filters (like filterTeams=)
  282. // are actually label filters with a fixed label key.
  283. func filterV1LabelAliasMappedFromList(rawFilterValues []string, labelName string) ast.FilterNode {
  284. var ops []ast.FilterNode
  285. labelName = prom.SanitizeLabelName(labelName)
  286. for _, filterValue := range rawFilterValues {
  287. filterValue = strings.TrimSpace(filterValue)
  288. filterValue, wildcard := parseWildcardEnd(filterValue)
  289. subFilter := toAllocationAliasOp(labelName, filterValue, wildcard)
  290. ops = append(ops, subFilter)
  291. }
  292. return opsToOr(ops)
  293. }
  294. // filterV1DoubleValueFromList creates an OR of key:value equality filters for
  295. // colon-split filter values.
  296. //
  297. // The v1 query language (e.g. "filterLabels=app:foo,l2:bar") uses OR within
  298. // a field (e.g. label[app] = foo OR label[l2] = bar)
  299. func filterV1DoubleValueFromList(rawFilterValuesUnsplit []string, filterField afilter.AllocationField) ast.FilterNode {
  300. var ops []ast.FilterNode
  301. for _, unsplit := range rawFilterValuesUnsplit {
  302. if unsplit != "" {
  303. split := strings.Split(unsplit, ":")
  304. if len(split) != 2 {
  305. log.Warnf("illegal key/value filter (ignoring): %s", unsplit)
  306. continue
  307. }
  308. labelName := prom.SanitizeLabelName(strings.TrimSpace(split[0]))
  309. val := strings.TrimSpace(split[1])
  310. val, wildcard := parseWildcardEnd(val)
  311. subFilter := toEqualOp(filterField, labelName, val, wildcard)
  312. ops = append(ops, subFilter)
  313. }
  314. }
  315. return opsToOr(ops)
  316. }
  317. // parseWildcardEnd checks if the given filter value is wildcarded, meaning
  318. // it ends in "*". If it does, it removes the suffix and returns the cleaned
  319. // string and true. Otherwise, it returns the same filter and false.
  320. //
  321. // parseWildcardEnd("kube*") = "kube", true
  322. // parseWildcardEnd("kube") = "kube", false
  323. func parseWildcardEnd(rawFilterValue string) (string, bool) {
  324. return strings.TrimSuffix(rawFilterValue, "*"), strings.HasSuffix(rawFilterValue, "*")
  325. }
  326. func push(a []ast.FilterNode, item ast.FilterNode) []ast.FilterNode {
  327. if item == nil {
  328. return a
  329. }
  330. return append(a, item)
  331. }
  332. func opsToOr(ops []ast.FilterNode) ast.FilterNode {
  333. if len(ops) == 0 {
  334. return nil
  335. }
  336. if len(ops) == 1 {
  337. return ops[0]
  338. }
  339. return &ast.OrOp{
  340. Operands: ops,
  341. }
  342. }
  343. func opsToAnd(ops []ast.FilterNode) ast.FilterNode {
  344. if len(ops) == 0 {
  345. return nil
  346. }
  347. if len(ops) == 1 {
  348. return ops[0]
  349. }
  350. return &ast.AndOp{
  351. Operands: ops,
  352. }
  353. }
  354. func toEqualOp[T ~string](field T, key string, value string, wildcard bool) ast.FilterNode {
  355. left := ast.Identifier{
  356. Field: DefaultFieldByName(field),
  357. Key: key,
  358. }
  359. right := value
  360. if wildcard {
  361. return &ast.ContainsPrefixOp{
  362. Left: left,
  363. Right: right,
  364. }
  365. }
  366. return &ast.EqualOp{
  367. Left: left,
  368. Right: right,
  369. }
  370. }
  371. func toContainsOp[T ~string](field T, key string, value string, wildcard bool) ast.FilterNode {
  372. left := ast.Identifier{
  373. Field: DefaultFieldByName(field),
  374. Key: key,
  375. }
  376. right := value
  377. if wildcard {
  378. return &ast.ContainsPrefixOp{
  379. Left: left,
  380. Right: right,
  381. }
  382. }
  383. return &ast.ContainsOp{
  384. Left: left,
  385. Right: right,
  386. }
  387. }
  388. func toAllocationAliasOp(labelName string, filterValue string, wildcard bool) *ast.OrOp {
  389. // labels.Contains(labelName)
  390. labelContainsKey := &ast.ContainsOp{
  391. Left: ast.Identifier{
  392. Field: afilter.DefaultFieldByName(afilter.FieldLabel),
  393. Key: "",
  394. },
  395. Right: labelName,
  396. }
  397. // annotations.Contains(labelName)
  398. annotationContainsKey := &ast.ContainsOp{
  399. Left: ast.Identifier{
  400. Field: afilter.DefaultFieldByName(afilter.FieldAnnotation),
  401. Key: "",
  402. },
  403. Right: labelName,
  404. }
  405. // labels[labelName] equals/startswith filterValue
  406. var labelSubFilter ast.FilterNode
  407. if wildcard {
  408. labelSubFilter = &ast.ContainsPrefixOp{
  409. Left: ast.Identifier{
  410. Field: afilter.DefaultFieldByName(afilter.FieldLabel),
  411. Key: labelName,
  412. },
  413. Right: filterValue,
  414. }
  415. } else {
  416. labelSubFilter = &ast.EqualOp{
  417. Left: ast.Identifier{
  418. Field: afilter.DefaultFieldByName(afilter.FieldLabel),
  419. Key: labelName,
  420. },
  421. Right: filterValue,
  422. }
  423. }
  424. // annotations[labelName] equals/startswith filterValue
  425. var annotationSubFilter ast.FilterNode
  426. if wildcard {
  427. annotationSubFilter = &ast.ContainsPrefixOp{
  428. Left: ast.Identifier{
  429. Field: afilter.DefaultFieldByName(afilter.FieldAnnotation),
  430. Key: labelName,
  431. },
  432. Right: filterValue,
  433. }
  434. } else {
  435. annotationSubFilter = &ast.EqualOp{
  436. Left: ast.Identifier{
  437. Field: afilter.DefaultFieldByName(afilter.FieldAnnotation),
  438. Key: labelName,
  439. },
  440. Right: filterValue,
  441. }
  442. }
  443. // Logically, this is equivalent to:
  444. // (labels.Contains(labelName) && labels[labelName] = filterValue) ||
  445. // (!labels.Contains(labelName) && annotations.Contains(labelName) && annotations[labelName] = filterValue)
  446. return &ast.OrOp{
  447. Operands: []ast.FilterNode{
  448. &ast.AndOp{
  449. Operands: []ast.FilterNode{
  450. labelContainsKey,
  451. labelSubFilter,
  452. },
  453. },
  454. &ast.AndOp{
  455. Operands: []ast.FilterNode{
  456. &ast.NotOp{
  457. Operand: ast.Clone(labelContainsKey),
  458. },
  459. annotationContainsKey,
  460. annotationSubFilter,
  461. },
  462. },
  463. },
  464. }
  465. }