From 3c9aa07c77a559d64333184322008322809a7f68 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Tue, 7 Apr 2026 10:47:26 +0300 Subject: [PATCH 1/2] add jq filter cache Signed-off-by: Pavel Okhlopkov --- pkg/filter/filter.go | 7 + pkg/filter/jq/apply.go | 90 ++++++++-- pkg/filter/jq/apply_test.go | 177 +++++++++++++++++++ pkg/hook/config/config_v0.go | 4 +- pkg/hook/config/config_v1.go | 4 +- pkg/kube_events_manager/filter.go | 18 +- pkg/kube_events_manager/filter_test.go | 104 ++++++++++- pkg/kube_events_manager/monitor_config.go | 23 +++ pkg/kube_events_manager/resource_informer.go | 7 +- 9 files changed, 397 insertions(+), 37 deletions(-) diff --git a/pkg/filter/filter.go b/pkg/filter/filter.go index 7551a49c..724318a4 100644 --- a/pkg/filter/filter.go +++ b/pkg/filter/filter.go @@ -4,3 +4,10 @@ type Filter interface { ApplyFilter(filterStr string, data map[string]any) ([]byte, error) FilterInfo() string } + +// CompiledFilter is a pre-compiled filter ready to be applied to data. +// Compile the filter expression once and reuse it across many Apply calls +// to avoid repeated parse/compile overhead. +type CompiledFilter interface { + Apply(data map[string]any) ([]byte, error) +} diff --git a/pkg/filter/jq/apply.go b/pkg/filter/jq/apply.go index 83905572..d9d69d3e 100644 --- a/pkg/filter/jq/apply.go +++ b/pkg/filter/jq/apply.go @@ -10,6 +10,7 @@ import ( ) var _ filter.Filter = (*Filter)(nil) +var _ filter.CompiledFilter = (*CompiledJqFilter)(nil) func NewFilter() *Filter { return &Filter{} @@ -35,6 +36,76 @@ func (f *Filter) ApplyFilter(jqFilter string, data map[string]any) ([]byte, erro } iter := query.Run(workData) + return collectResults(iter) +} + +func (f *Filter) FilterInfo() string { + return "jqFilter implementation: using itchyny/gojq" +} + +// CompiledJqFilter holds a pre-compiled gojq program. Compile once and reuse +// across many Apply calls to eliminate repeated parse+compile overhead. +type CompiledJqFilter struct { + code *gojq.Code + originalStr string +} + +// Compile parses and compiles jqFilter once. The returned *CompiledJqFilter is +// safe for concurrent use and can be reused for every event that carries the +// same filter expression. +func Compile(jqFilter string) (*CompiledJqFilter, error) { + query, err := gojq.Parse(jqFilter) + if err != nil { + return nil, err + } + + code, err := gojq.Compile(query) + if err != nil { + return nil, err + } + + return &CompiledJqFilter{code: code, originalStr: jqFilter}, nil +} + +// Apply executes the pre-compiled jq program against data. +func (c *CompiledJqFilter) Apply(data map[string]any) ([]byte, error) { + var workData any + var err error + if data == nil { + workData = nil + } else { + workData, err = deepCopyAny(data) + if err != nil { + return nil, err + } + } + + iter := c.code.Run(workData) + return collectResults(iter) +} + +// String returns the original jq filter expression for diagnostics. +func (c *CompiledJqFilter) String() string { + return c.originalStr +} + +func deepCopyAny(input any) (any, error) { + if input == nil { + return nil, nil + } + data, err := json.Marshal(input) + if err != nil { + return nil, err + } + var output any + if err := json.Unmarshal(data, &output); err != nil { + return nil, err + } + return output, nil +} + +// collectResults drains a gojq iterator and serialises the results to JSON. +func collectResults(iter gojq.Iter) ([]byte, error) { result := make([]any, 0) for { v, ok := iter.Next() @@ -60,22 +131,3 @@ func (f *Filter) ApplyFilter(jqFilter string, data map[string]any) ([]byte, erro return json.Marshal(result) } } - -func (f *Filter) FilterInfo() string { - return "jqFilter implementation: using itchyny/gojq" -} - -func deepCopyAny(input any) (any, error) { - if input == nil { - return nil, nil - } - data, err := json.Marshal(input) - if err != nil { - return nil, err - } - var output any - if err := json.Unmarshal(data, &output); err != nil { - return nil, err - } - return output, nil -} diff --git a/pkg/filter/jq/apply_test.go b/pkg/filter/jq/apply_test.go index 39b24374..f867fa8f 100644 --- a/pkg/filter/jq/apply_test.go +++ b/pkg/filter/jq/apply_test.go @@ -200,3 +200,180 @@ func Test_deepCopyAny(t *testing.T) { g.Expect(err).ShouldNot(BeNil()) g.Expect(copyInvalid).Should(BeNil()) } + +// ---- Compile / CompiledJqFilter tests ---- + +func Test_Compile_ValidExpression(t *testing.T) { + g := NewWithT(t) + + cf, err := Compile(`.metadata.name`) + g.Expect(err).Should(BeNil()) + g.Expect(cf).ShouldNot(BeNil()) + g.Expect(cf.String()).Should(Equal(`.metadata.name`)) +} + +func Test_Compile_InvalidExpression(t *testing.T) { + g := NewWithT(t) + + cf, err := Compile(`this is not jq`) + g.Expect(err).ShouldNot(BeNil()) + g.Expect(cf).Should(BeNil()) +} + +func Test_Compile_EmptyExpression(t *testing.T) { + g := NewWithT(t) + + // Empty string is not valid jq. + cf, err := Compile(``) + g.Expect(err).ShouldNot(BeNil()) + g.Expect(cf).Should(BeNil()) +} + +func Test_CompiledJqFilter_Apply_SingleDocumentModification(t *testing.T) { + g := NewWithT(t) + + cf, err := Compile(`. + {"status": "active"}`) + g.Expect(err).Should(BeNil()) + + result, err := cf.Apply(map[string]any{"name": "Alice", "age": 25}) + g.Expect(err).Should(BeNil()) + + var got any + g.Expect(json.Unmarshal(result, &got)).Should(BeNil()) + g.Expect(got).Should(Equal(map[string]any{"name": "Alice", "age": float64(25), "status": "active"})) +} + +func Test_CompiledJqFilter_Apply_ExtractField(t *testing.T) { + g := NewWithT(t) + + cf, err := Compile(`.metadata.labels`) + g.Expect(err).Should(BeNil()) + + input := map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{"app": "foo", "env": "prod"}, + }, + } + result, err := cf.Apply(input) + g.Expect(err).Should(BeNil()) + + var got any + g.Expect(json.Unmarshal(result, &got)).Should(BeNil()) + g.Expect(got).Should(Equal(map[string]any{"app": "foo", "env": "prod"})) +} + +func Test_CompiledJqFilter_Apply_MultipleResults(t *testing.T) { + g := NewWithT(t) + + cf, err := Compile(`.users[] | .name`) + g.Expect(err).Should(BeNil()) + + input := map[string]any{ + "users": []any{ + map[string]any{"name": "Alice"}, + map[string]any{"name": "Bob"}, + }, + } + result, err := cf.Apply(input) + g.Expect(err).Should(BeNil()) + + var got []any + g.Expect(json.Unmarshal(result, &got)).Should(BeNil()) + g.Expect(got).Should(ConsistOf("Alice", "Bob")) +} + +func Test_CompiledJqFilter_Apply_NullResult(t *testing.T) { + g := NewWithT(t) + + cf, err := Compile(`.nonexistent`) + g.Expect(err).Should(BeNil()) + + result, err := cf.Apply(map[string]any{"name": "Alice"}) + g.Expect(err).Should(BeNil()) + g.Expect(result).Should(Equal([]byte("null"))) +} + +func Test_CompiledJqFilter_Apply_NilInput(t *testing.T) { + g := NewWithT(t) + + cf, err := Compile(`.`) + g.Expect(err).Should(BeNil()) + + result, err := cf.Apply(nil) + g.Expect(err).Should(BeNil()) + g.Expect(result).ShouldNot(BeNil()) +} + +func Test_CompiledJqFilter_Apply_RuntimeError(t *testing.T) { + g := NewWithT(t) + + // .foo on a non-object (null) causes a runtime jq error. + cf, err := Compile(`.foo`) + g.Expect(err).Should(BeNil()) + + // Passing nil as input means workData == nil; trying .foo on null returns null. + result, err := cf.Apply(nil) + g.Expect(err).Should(BeNil()) + g.Expect(result).Should(Equal([]byte("null"))) +} + +func Test_CompiledJqFilter_Apply_UnmarshalableInput(t *testing.T) { + g := NewWithT(t) + + cf, err := Compile(`.`) + g.Expect(err).Should(BeNil()) + + _, err = cf.Apply(map[string]any{"ch": make(chan int)}) + g.Expect(err).ShouldNot(BeNil()) +} + +// Test_CompiledJqFilter_Reuse verifies that the same compiled filter can be +// applied to different inputs and produces correct independent results. +func Test_CompiledJqFilter_Reuse(t *testing.T) { + g := NewWithT(t) + + cf, err := Compile(`.spec.replicas`) + g.Expect(err).Should(BeNil()) + + inputs := []map[string]any{ + {"spec": map[string]any{"replicas": float64(1)}}, + {"spec": map[string]any{"replicas": float64(3)}}, + {"spec": map[string]any{"replicas": float64(5)}}, + } + expected := []float64{1, 3, 5} + + for i, input := range inputs { + result, err := cf.Apply(input) + g.Expect(err).Should(BeNil(), "input index %d", i) + + var got float64 + g.Expect(json.Unmarshal(result, &got)).Should(BeNil(), "input index %d", i) + g.Expect(got).Should(Equal(expected[i]), "input index %d", i) + } +} + +// Test_Compile_ProducesIdenticalResultsToApplyFilter verifies that the +// compiled path and the interpreted path yield identical output. +func Test_Compile_ProducesIdenticalResultsToApplyFilter(t *testing.T) { + g := NewWithT(t) + + filterStr := `.metadata | {name, namespace}` + input := map[string]any{ + "metadata": map[string]any{ + "name": "my-pod", + "namespace": "default", + "labels": map[string]any{"app": "foo"}, + }, + } + + interpreted := NewFilter() + resultInterpreted, err := interpreted.ApplyFilter(filterStr, input) + g.Expect(err).Should(BeNil()) + + cf, err := Compile(filterStr) + g.Expect(err).Should(BeNil()) + resultCompiled, err := cf.Apply(input) + g.Expect(err).Should(BeNil()) + + g.Expect(resultCompiled).Should(Equal(resultInterpreted)) +} diff --git a/pkg/hook/config/config_v0.go b/pkg/hook/config/config_v0.go index 4fb3d89b..a8b14ebd 100644 --- a/pkg/hook/config/config_v0.go +++ b/pkg/hook/config/config_v0.go @@ -107,7 +107,9 @@ func (cv0 *HookConfigV0) ConvertAndCheck(c *HookConfig) error { }) } monitor.WithLabelSelector(kubeCfg.Selector) - monitor.JqFilter = kubeCfg.JqFilter + if err := monitor.WithJqFilter(kubeCfg.JqFilter); err != nil { + return fmt.Errorf("kubernetes config [%s] jqFilter: %w", kubeCfg.Name, err) + } kubeConfig := htypes.OnKubernetesEventConfig{} kubeConfig.Monitor = monitor diff --git a/pkg/hook/config/config_v1.go b/pkg/hook/config/config_v1.go index 9275c0fc..1b802fa9 100644 --- a/pkg/hook/config/config_v1.go +++ b/pkg/hook/config/config_v1.go @@ -129,7 +129,9 @@ func (cv1 *HookConfigV1) ConvertAndCheck(c *HookConfig) error { monitor.WithFieldSelector((*kemtypes.FieldSelector)(kubeCfg.FieldSelector)) monitor.WithNamespaceSelector((*kemtypes.NamespaceSelector)(kubeCfg.Namespace)) monitor.WithLabelSelector(kubeCfg.LabelSelector) - monitor.JqFilter = kubeCfg.JqFilter + if err := monitor.WithJqFilter(kubeCfg.JqFilter); err != nil { + return fmt.Errorf("invalid kubernetes config [%d] jqFilter: %w", i, err) + } // executeHookOnEvent is a priority if kubeCfg.ExecuteHookOnEvents != nil { monitor.WithEventTypes(kubeCfg.ExecuteHookOnEvents) diff --git a/pkg/kube_events_manager/filter.go b/pkg/kube_events_manager/filter.go index 7b704cd0..fccb83f0 100644 --- a/pkg/kube_events_manager/filter.go +++ b/pkg/kube_events_manager/filter.go @@ -15,16 +15,18 @@ import ( utils_checksum "github.com/flant/shell-operator/pkg/utils/checksum" ) -// applyFilter filters object json representation with jq expression, calculate checksum -// over result and return ObjectAndFilterResult. If jqFilter is empty, no filter -// is required and checksum is calculated over full json representation of the object. -func applyFilter(jqFilter string, fl filter.Filter, filterFn func(obj *unstructured.Unstructured) (result interface{}, err error), obj *unstructured.Unstructured) (*kemtypes.ObjectAndFilterResult, error) { +// applyFilter filters object json representation with a pre-compiled jq expression, +// calculates checksum over the result and returns ObjectAndFilterResult. +// If compiledFilter is nil, no jq filtering is applied and checksum is calculated +// over full json representation of the object. +// jqFilterStr is stored in result metadata for informational purposes only. +func applyFilter(compiledFilter filter.CompiledFilter, jqFilterStr string, filterFn func(obj *unstructured.Unstructured) (result interface{}, err error), obj *unstructured.Unstructured) (*kemtypes.ObjectAndFilterResult, error) { defer trace.StartRegion(context.Background(), "ApplyJqFilter").End() res := &kemtypes.ObjectAndFilterResult{ Object: obj, } - res.Metadata.JqFilter = jqFilter + res.Metadata.JqFilter = jqFilterStr res.Metadata.ResourceId = resourceId(obj) // If filterFn is passed, run it and return result. @@ -46,16 +48,14 @@ func applyFilter(jqFilter string, fl filter.Filter, filterFn func(obj *unstructu } // Render obj to JSON text to apply jq filter. - if jqFilter == "" { + if compiledFilter == nil { data, err := json.Marshal(obj) if err != nil { return nil, err } res.Metadata.Checksum = utils_checksum.CalculateChecksum(string(data)) } else { - var err error - var filtered []byte - filtered, err = fl.ApplyFilter(jqFilter, obj.UnstructuredContent()) + filtered, err := compiledFilter.Apply(obj.UnstructuredContent()) if err != nil { return nil, fmt.Errorf("jqFilter: %v", err) } diff --git a/pkg/kube_events_manager/filter_test.go b/pkg/kube_events_manager/filter_test.go index 5c1961ab..90785838 100644 --- a/pkg/kube_events_manager/filter_test.go +++ b/pkg/kube_events_manager/filter_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/flant/shell-operator/pkg/filter/jq" @@ -13,10 +14,109 @@ import ( func TestApplyFilter(t *testing.T) { t.Run("filter func with error", func(t *testing.T) { uns := &unstructured.Unstructured{Object: map[string]interface{}{"foo": "bar"}} - filter := jq.NewFilter() - _, err := applyFilter("", filter, filterFuncWithError, uns) + _, err := applyFilter(nil, "", filterFuncWithError, uns) assert.EqualError(t, err, "filterFn (github.com/flant/shell-operator/pkg/kube_events_manager.filterFuncWithError) contains an error: invalid character 'a' looking for beginning of value") }) + + t.Run("nil compiledFilter computes checksum over full object", func(t *testing.T) { + uns := &unstructured.Unstructured{Object: map[string]interface{}{"foo": "bar"}} + res, err := applyFilter(nil, "", nil, uns) + require.NoError(t, err) + assert.NotEmpty(t, res.Metadata.Checksum) + assert.Nil(t, res.FilterResult) + assert.Equal(t, "", res.Metadata.JqFilter) + }) + + t.Run("compiled filter is applied and checksum calculated over result", func(t *testing.T) { + uns := &unstructured.Unstructured{Object: map[string]interface{}{"spec": map[string]interface{}{"replicas": float64(2)}}} + cf, err := jq.Compile(`.spec`) + require.NoError(t, err) + + res, err := applyFilter(cf, ".spec", nil, uns) + require.NoError(t, err) + assert.Equal(t, ".spec", res.Metadata.JqFilter) + assert.NotEmpty(t, res.Metadata.Checksum) + filterStr, ok := res.FilterResult.(string) + require.True(t, ok) + assert.Contains(t, filterStr, "replicas") + }) + + t.Run("checksum differs for different compiled filter results", func(t *testing.T) { + cf, err := jq.Compile(`.metadata.name`) + require.NoError(t, err) + + uns1 := &unstructured.Unstructured{Object: map[string]interface{}{"metadata": map[string]interface{}{"name": "pod-a"}}} + uns2 := &unstructured.Unstructured{Object: map[string]interface{}{"metadata": map[string]interface{}{"name": "pod-b"}}} + + res1, err := applyFilter(cf, ".metadata.name", nil, uns1) + require.NoError(t, err) + res2, err := applyFilter(cf, ".metadata.name", nil, uns2) + require.NoError(t, err) + + assert.NotEqual(t, res1.Metadata.Checksum, res2.Metadata.Checksum) + }) + + t.Run("checksum is same for same compiled filter result", func(t *testing.T) { + cf, err := jq.Compile(`.metadata.name`) + require.NoError(t, err) + + uns := &unstructured.Unstructured{Object: map[string]interface{}{"metadata": map[string]interface{}{"name": "pod-a"}}} + + res1, err := applyFilter(cf, ".metadata.name", nil, uns) + require.NoError(t, err) + res2, err := applyFilter(cf, ".metadata.name", nil, uns) + require.NoError(t, err) + + assert.Equal(t, res1.Metadata.Checksum, res2.Metadata.Checksum) + }) +} + +// TestMonitorConfig_WithJqFilter validates compilation at MonitorConfig construction time. +func TestMonitorConfig_WithJqFilter(t *testing.T) { + t.Run("valid expression compiles and sets both fields", func(t *testing.T) { + mc := &MonitorConfig{} + require.NoError(t, mc.WithJqFilter(`.metadata.labels`)) + assert.Equal(t, `.metadata.labels`, mc.JqFilter) + assert.NotNil(t, mc.CompiledJqFilter) + }) + + t.Run("empty expression clears compiled filter", func(t *testing.T) { + mc := &MonitorConfig{} + require.NoError(t, mc.WithJqFilter(``)) + assert.Equal(t, ``, mc.JqFilter) + assert.Nil(t, mc.CompiledJqFilter) + }) + + t.Run("invalid expression returns error and leaves config unchanged", func(t *testing.T) { + mc := &MonitorConfig{} + err := mc.WithJqFilter(`not valid jq!!!`) + require.Error(t, err) + assert.Contains(t, err.Error(), "compile jqFilter") + }) + + t.Run("compiled filter produces same result as raw ApplyFilter", func(t *testing.T) { + mc := &MonitorConfig{} + require.NoError(t, mc.WithJqFilter(`.spec`)) + + uns := &unstructured.Unstructured{Object: map[string]interface{}{ + "spec": map[string]interface{}{"replicas": float64(3)}, + }} + + resCompiled, err := applyFilter(mc.CompiledJqFilter, mc.JqFilter, nil, uns) + require.NoError(t, err) + + interpreted := jq.NewFilter() + resInterpreted, err := applyFilter(nil, "", nil, uns) // no filter → full checksum + require.NoError(t, err) + + // Ensure the compiled filter actually applies transformation (not full-object checksum). + rawInterpreted, err := interpreted.ApplyFilter(`.spec`, uns.UnstructuredContent()) + require.NoError(t, err) + assert.Equal(t, string(rawInterpreted), resCompiled.FilterResult) + + // And that the unchained path still works. + assert.NotEqual(t, resCompiled.Metadata.Checksum, resInterpreted.Metadata.Checksum) + }) } func filterFuncWithError(_ *unstructured.Unstructured) (interface{}, error) { diff --git a/pkg/kube_events_manager/monitor_config.go b/pkg/kube_events_manager/monitor_config.go index 27df1a17..e2d9c4c5 100644 --- a/pkg/kube_events_manager/monitor_config.go +++ b/pkg/kube_events_manager/monitor_config.go @@ -1,10 +1,14 @@ package kubeeventsmanager import ( + "fmt" + "github.com/deckhouse/deckhouse/pkg/log" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "github.com/flant/shell-operator/pkg/filter" + "github.com/flant/shell-operator/pkg/filter/jq" kemtypes "github.com/flant/shell-operator/pkg/kube_events_manager/types" ) @@ -25,12 +29,31 @@ type MonitorConfig struct { LabelSelector *metav1.LabelSelector FieldSelector *kemtypes.FieldSelector JqFilter string + CompiledJqFilter filter.CompiledFilter Logger *log.Logger Mode kemtypes.KubeEventMode KeepFullObjectsInMemory bool FilterFunc func(*unstructured.Unstructured) (interface{}, error) } +// WithJqFilter sets the JQ filter expression and compiles it for reuse on every +// event. Returns an error if the expression cannot be parsed or compiled. +func (c *MonitorConfig) WithJqFilter(jqFilter string) error { + c.JqFilter = jqFilter + if jqFilter == "" { + c.CompiledJqFilter = nil + return nil + } + + compiled, err := jq.Compile(jqFilter) + if err != nil { + return fmt.Errorf("compile jqFilter: %w", err) + } + + c.CompiledJqFilter = compiled + return nil +} + func (c *MonitorConfig) WithEventTypes(types []kemtypes.WatchEventType) *MonitorConfig { if types == nil { c.EventTypes = []kemtypes.WatchEventType{ diff --git a/pkg/kube_events_manager/resource_informer.go b/pkg/kube_events_manager/resource_informer.go index a8df5d28..d6d77fd8 100644 --- a/pkg/kube_events_manager/resource_informer.go +++ b/pkg/kube_events_manager/resource_informer.go @@ -17,7 +17,6 @@ import ( "k8s.io/client-go/tools/cache" klient "github.com/flant/kube-client/client" - "github.com/flant/shell-operator/pkg/filter/jq" kemtypes "github.com/flant/shell-operator/pkg/kube_events_manager/types" "github.com/flant/shell-operator/pkg/metrics" "github.com/flant/shell-operator/pkg/utils/measure" @@ -227,8 +226,7 @@ func (ei *resourceInformer) loadExistedObjects() error { defer measure.Duration(func(d time.Duration) { ei.metricStorage.HistogramObserve(metrics.KubeJqFilterDurationSeconds, d.Seconds(), ei.Monitor.Metadata.MetricLabels, nil) })() - filter := jq.NewFilter() - objFilterRes, err = applyFilter(ei.Monitor.JqFilter, filter, ei.Monitor.FilterFunc, &obj) + objFilterRes, err = applyFilter(ei.Monitor.CompiledJqFilter, ei.Monitor.JqFilter, ei.Monitor.FilterFunc, &obj) }() if err != nil { @@ -307,8 +305,7 @@ func (ei *resourceInformer) handleWatchEvent(object interface{}, eventType kemty defer measure.Duration(func(d time.Duration) { ei.metricStorage.HistogramObserve(metrics.KubeJqFilterDurationSeconds, d.Seconds(), ei.Monitor.Metadata.MetricLabels, nil) })() - filter := jq.NewFilter() - objFilterRes, err = applyFilter(ei.Monitor.JqFilter, filter, ei.Monitor.FilterFunc, obj) + objFilterRes, err = applyFilter(ei.Monitor.CompiledJqFilter, ei.Monitor.JqFilter, ei.Monitor.FilterFunc, obj) }() if err != nil { log.Error("handleWatchEvent: applyFilter error", From 26991ba148dc2686222e889ee09acf83ddc111ba Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Tue, 7 Apr 2026 11:22:23 +0300 Subject: [PATCH 2/2] lint Signed-off-by: Pavel Okhlopkov --- pkg/filter/jq/apply.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/filter/jq/apply.go b/pkg/filter/jq/apply.go index d9d69d3e..b32ac7df 100644 --- a/pkg/filter/jq/apply.go +++ b/pkg/filter/jq/apply.go @@ -9,8 +9,10 @@ import ( "github.com/flant/shell-operator/pkg/filter" ) -var _ filter.Filter = (*Filter)(nil) -var _ filter.CompiledFilter = (*CompiledJqFilter)(nil) +var ( + _ filter.Filter = (*Filter)(nil) + _ filter.CompiledFilter = (*CompiledJqFilter)(nil) +) func NewFilter() *Filter { return &Filter{}