-
Notifications
You must be signed in to change notification settings - Fork 174
Expand file tree
/
Copy pathfilter.go
More file actions
279 lines (259 loc) · 9.71 KB
/
Copy pathfilter.go
File metadata and controls
279 lines (259 loc) · 9.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
// Package filter compiles and evaluates CEL boolean expressions used
// by /api/search and /api/issues to apply structured predicates over
// K8s objects and Issue rows.
//
// Why CEL: bounded execution (no DoS from a hallucinated expression),
// type-checked at compile time, K8s-ecosystem alignment, and 5-10×
// faster evaluation than gojq at scale. The downside (less LLM
// fluency cold) is offset by a small primer in the MCP tool
// description and the schema-aware error messages CEL produces when
// the agent misnames a field.
//
// Two compile entry points:
//
// CompileObjectFilter — bindings shaped to a K8s object:
// kind, apiVersion, metadata, spec, status, labels, annotations
//
// CompileIssueFilter — bindings shaped to an issues.Issue:
// severity, source, category, category_group, kind, group, ns,
// name, reason, message, count, first_seen, last_seen, grouping_scope,
// restart_count, last_terminated_reason
//
// Both return a Filter whose Match(activation) yields (bool, error).
// Compile errors are returned verbatim (CEL's parser produces
// position-tagged messages — pass them through to the caller, they're
// the most actionable thing the LLM gets).
package filter
import (
"errors"
"fmt"
"sync"
"time"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
)
// Filter is a compiled boolean predicate.
type Filter struct {
expr string
program cel.Program
}
// Source returns the original expression string. Useful for error
// messages and cache keys.
func (f *Filter) Source() string { return f.expr }
// Match evaluates the filter against the activation map. The map is
// a flat top-level binding (e.g. {"kind": "Pod", "metadata": {...}}).
// Returns (false, nil) when the filter evaluates to a falsy non-error
// value, (true, nil) when truthy, and (false, err) on a runtime error
// — callers typically treat eval errors as "this object doesn't match"
// and continue, so the user sees zero results rather than an opaque
// 500. The bool returned in the error case is always false.
func (f *Filter) Match(activation map[string]any) (bool, error) {
out, _, err := f.program.Eval(activation)
if err != nil {
return false, fmt.Errorf("eval: %w", err)
}
b, ok := out.(types.Bool)
if !ok {
return false, fmt.Errorf("filter must return bool, got %s", out.Type().TypeName())
}
return bool(b), nil
}
// envObject is the CEL environment for K8s-object filters. Built once
// and reused — building takes a few hundred microseconds and creating
// a new env per request would dominate evaluation cost on repeat
// queries.
//
// MIRROR: radar-hub/internal/server/celcheck.go declares the same
// bindings (celObjectEnv) for fail-fast pre-validation at the hub.
// If you change the binding list, update both places in lockstep —
// there's no shared package and no compile-time check that catches
// drift. Hub's TestCelEnv_ObjectDeclarations exercises the bindings
// from its side; radar's filter_test.go does the same here.
var envObject = mustNewEnv(
cel.Variable("kind", cel.StringType),
cel.Variable("apiVersion", cel.StringType),
cel.Variable("metadata", cel.DynType),
cel.Variable("spec", cel.DynType),
cel.Variable("status", cel.DynType),
cel.Variable("labels", cel.MapType(cel.StringType, cel.StringType)),
cel.Variable("annotations", cel.MapType(cel.StringType, cel.StringType)),
)
// envIssue is the CEL environment for /api/issues filters. The shape
// mirrors issues.Issue's JSON form so an LLM that's seen one row of
// output can write a filter against it without docs.
// envIssue uses `ns` instead of `namespace` because the latter is a
// CEL reserved identifier — bare references like `namespace == "x"`
// fail at parse time even when the variable is declared. We took the
// short form rather than fight cel-go's reservation list; `ns:` is
// also the short modifier in the search query parser, so the two
// surfaces stay parallel.
var envIssue = mustNewEnv(
cel.Variable("severity", cel.StringType),
cel.Variable("source", cel.StringType),
cel.Variable("category", cel.StringType),
cel.Variable("category_group", cel.StringType),
cel.Variable("kind", cel.StringType),
cel.Variable("group", cel.StringType),
cel.Variable("ns", cel.StringType),
cel.Variable("name", cel.StringType),
cel.Variable("reason", cel.StringType),
cel.Variable("message", cel.StringType),
cel.Variable("count", cel.IntType),
// No `cluster` binding: a single Radar is one cluster. Cross-cluster scoping
// is the hub's `clusters=`/target mechanism (applied at fan-out), not a
// per-issue CEL predicate — Issue.Cluster was always empty here, so a
// forwarded `cluster == "x"` matched nothing.
// first_seen/last_seen are int unix-second timestamps so the agent can
// write `first_seen > timestamp("2025-01-01T00:00:00Z")` or compare
// against a "now - 1h" delta. Prefer first_seen for "issues older
// than…": last_seen churns to compose-time on every poll.
cel.Variable("first_seen", cel.IntType),
cel.Variable("last_seen", cel.IntType),
// grouping_scope (node|workload|…) slices node-level vs workload issues;
// restart_count + last_terminated_reason are the chronic-vs-acute fields.
cel.Variable("grouping_scope", cel.StringType),
cel.Variable("restart_count", cel.IntType),
cel.Variable("last_terminated_reason", cel.StringType),
)
func mustNewEnv(opts ...cel.EnvOption) *cel.Env {
env, err := cel.NewEnv(opts...)
if err != nil {
panic(fmt.Sprintf("cel.NewEnv: %v", err))
}
return env
}
// CompileObjectFilter compiles a CEL expression that runs against a
// K8s object's bindings (kind, metadata, spec, status, labels,
// annotations). Returns the compiler's diagnostic verbatim on parse
// or type-check failure — that's what the agent retries against.
func CompileObjectFilter(expr string) (*Filter, error) {
return compileWith(envObject, expr)
}
// CompileIssueFilter compiles a CEL expression against the Issue
// row bindings (severity, source, kind, …, count, last_seen).
func CompileIssueFilter(expr string) (*Filter, error) {
return compileWith(envIssue, expr)
}
func compileWith(env *cel.Env, expr string) (*Filter, error) {
if expr == "" {
return nil, errors.New("empty filter expression")
}
ast, issues := env.Compile(expr)
if issues != nil && issues.Err() != nil {
return nil, issues.Err()
}
if ast.OutputType() != cel.BoolType {
return nil, fmt.Errorf("filter must return bool, got %s", ast.OutputType().String())
}
prg, err := env.Program(
ast,
// Cap evaluation cost. CEL is bounded by language design but
// `..` (recursive descent) doesn't exist; the cost limit
// guards against pathological-but-legal expressions like
// deeply-nested macros.
cel.CostLimit(1_000_000),
)
if err != nil {
return nil, fmt.Errorf("program: %w", err)
}
return &Filter{expr: expr, program: prg}, nil
}
// ---------------------------------------------------------------------------
// LRU-ish cache for compiled programs.
//
// Caching hot expressions matters: for an LLM that fan-outs a fleet
// query and re-issues with the same filter on refetch, paying the
// compile cost (~200µs) per request adds up. Cache compiled programs
// keyed on (envName + expression).
//
// The map is bounded to maxCacheEntries; once full, we evict the
// oldest entries by insertion time. Not strict LRU — the access
// pattern here is "compile-once, eval-many" so we don't need to
// re-rank on hit. Entries are also TTL'd so an expression we
// haven't seen in a while drops out instead of pinning forever.
// ---------------------------------------------------------------------------
const (
// maxCacheEntries — 256 covers typical agent + UI working sets
// while keeping the O(n) eviction sweep fast. Tuned by intuition;
// revisit when telemetry exists.
maxCacheEntries = 256
// cacheTTL — 1h roughly matches a typical AI agent session.
// Beyond that a "stale" compile is no real harm (the same
// expression compiles to the same program) but bounding the
// working set keeps the memory profile flat for long-lived hubs.
cacheTTL = 1 * time.Hour
)
type cacheEntry struct {
filter *Filter
added time.Time
}
type cache struct {
mu sync.Mutex
entries map[string]cacheEntry // key = "obj:" or "issue:" + expr
}
var defaultCache = &cache{entries: map[string]cacheEntry{}}
func (c *cache) get(key string) *Filter {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[key]
if !ok {
return nil
}
if time.Since(e.added) > cacheTTL {
delete(c.entries, key)
return nil
}
return e.filter
}
func (c *cache) put(key string, f *Filter) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.entries) >= maxCacheEntries {
// Evict the oldest entry. O(n) but n=256 and this happens
// on miss only — cheaper than a heap in practice.
var oldestKey string
var oldestAt time.Time
for k, e := range c.entries {
if oldestKey == "" || e.added.Before(oldestAt) {
oldestKey = k
oldestAt = e.added
}
}
delete(c.entries, oldestKey)
}
c.entries[key] = cacheEntry{filter: f, added: time.Now()}
}
// CachedObjectFilter compiles or returns the cached compilation of an
// object-scoped filter expression. Caller-facing wrapper most callers
// should use.
func CachedObjectFilter(expr string) (*Filter, error) {
if expr == "" {
return nil, nil
}
key := "obj:" + expr
if f := defaultCache.get(key); f != nil {
return f, nil
}
f, err := CompileObjectFilter(expr)
if err != nil {
return nil, err
}
defaultCache.put(key, f)
return f, nil
}
// CachedIssueFilter is the issue-scoped twin of CachedObjectFilter.
func CachedIssueFilter(expr string) (*Filter, error) {
if expr == "" {
return nil, nil
}
key := "issue:" + expr
if f := defaultCache.get(key); f != nil {
return f, nil
}
f, err := CompileIssueFilter(expr)
if err != nil {
return nil, err
}
defaultCache.put(key, f)
return f, nil
}