master
go 448 lines 11.8 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package collecttest
4
5 import (
6 "fmt"
7 "sort"
8 "strings"
9 "testing"
10
11 "github.com/netdata/netdata/go/plugins/pkg/matcher"
12 "github.com/netdata/netdata/go/plugins/pkg/metrix"
13 metrixselector "github.com/netdata/netdata/go/plugins/pkg/metrix/selector"
14 "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
15 "github.com/netdata/netdata/go/plugins/plugin/framework/charttpl"
16 )
17
18 const (
19 histogramBucketLabel = "le"
20 summaryQuantileLabel = "quantile"
21 )
22
23 type chartCoverage struct {
24 ActualByContext map[string]map[string]struct{}
25 ExpectedByContext map[string][]string
26 }
27
28 type scopedChartCoverage struct {
29 ScopeKey string
30 Coverage chartCoverage
31 }
32
33 // ChartCoverageExpectation defines per-scenario chart coverage assertions.
34 type ChartCoverageExpectation struct {
35 // ExcludeContextPatterns are glob patterns for contexts ignored from
36 // template-derived coverage assertions in this scenario.
37 ExcludeContextPatterns []string
38 // RequiredContexts defines additional required context/dimension checks.
39 // Key: chart context, value: required dimension names.
40 RequiredContexts map[string][]string
41 }
42
43 // AssertChartCoverage validates chart materialization against template-derived
44 // coverage and optional explicit required context checks.
45 func AssertChartCoverage(
46 t *testing.T,
47 collector interface {
48 MetricStore() metrix.CollectorStore
49 ChartTemplateYAML() string
50 },
51 exp ChartCoverageExpectation,
52 ) {
53 t.Helper()
54
55 if collector == nil {
56 t.Fatalf("collecttest: nil collector")
57 return
58 }
59 store := collector.MetricStore()
60 if store == nil {
61 t.Fatalf("collecttest: nil metric store")
62 return
63 }
64 templateYAML := collector.ChartTemplateYAML()
65
66 coverages, err := buildChartCoveragesFromStore(templateYAML, 1, store, exp.ExcludeContextPatterns)
67 if err != nil {
68 t.Fatalf("collecttest: build chart coverage: %v", err)
69 return
70 }
71
72 for _, scoped := range coverages {
73 for contextName, dims := range scoped.Coverage.ExpectedByContext {
74 requireContextDims(t, scoped.Coverage.ActualByContext, contextName, dims, scoped.ScopeKey)
75 }
76 for contextName, dims := range exp.RequiredContexts {
77 requireContextDims(t, scoped.Coverage.ActualByContext, contextName, dims, scoped.ScopeKey)
78 }
79 }
80 }
81
82 func buildChartCoveragesFromStore(
83 templateYAML string,
84 revision uint64,
85 store metrix.CollectorStore,
86 excludeContextPatterns []string,
87 ) ([]scopedChartCoverage, error) {
88 reader := store.Read(metrix.ReadRaw(), metrix.ReadFlatten())
89 scopes := reader.HostScopes()
90 if len(scopes) == 0 {
91 coverage, err := buildChartCoverage(templateYAML, revision, reader, excludeContextPatterns)
92 if err != nil {
93 return nil, err
94 }
95 return []scopedChartCoverage{{Coverage: coverage}}, nil
96 }
97
98 out := make([]scopedChartCoverage, 0, len(scopes))
99 for _, scope := range scopes {
100 reader := store.Read(metrix.ReadRaw(), metrix.ReadFlatten(), metrix.ReadHostScope(scope.ScopeKey))
101 coverage, err := buildChartCoverage(templateYAML, revision, reader, excludeContextPatterns)
102 if err != nil {
103 return nil, err
104 }
105 out = append(out, scopedChartCoverage{ScopeKey: scope.ScopeKey, Coverage: coverage})
106 }
107 return out, nil
108 }
109
110 func buildChartCoverage(
111 templateYAML string,
112 revision uint64,
113 reader metrix.Reader,
114 excludeContextPatterns []string,
115 ) (chartCoverage, error) {
116 if reader == nil {
117 return chartCoverage{}, fmt.Errorf("collecttest: nil reader")
118 }
119
120 contextMatchers, err := compileContextGlobMatchers(excludeContextPatterns)
121 if err != nil {
122 return chartCoverage{}, err
123 }
124
125 plan, err := buildPlanFromTemplate(templateYAML, revision, reader)
126 if err != nil {
127 return chartCoverage{}, err
128 }
129
130 actualByContext := materializedContextsByPattern(plan, contextMatchers)
131 expectedByContext, err := expectedTemplateCoverage(templateYAML, reader, contextMatchers)
132 if err != nil {
133 return chartCoverage{}, err
134 }
135
136 return chartCoverage{
137 ActualByContext: actualByContext,
138 ExpectedByContext: expectedByContext,
139 }, nil
140 }
141
142 func compileContextGlobMatchers(patterns []string) ([]matcher.Matcher, error) {
143 out := make([]matcher.Matcher, 0, len(patterns))
144 for i, pattern := range patterns {
145 pattern = strings.TrimSpace(pattern)
146 if pattern == "" {
147 continue
148 }
149 m, err := matcher.NewGlobMatcher(pattern)
150 if err != nil {
151 return nil, fmt.Errorf("collecttest: invalid context glob pattern[%d]=%q: %w", i, pattern, err)
152 }
153 out = append(out, m)
154 }
155 return out, nil
156 }
157
158 func matchesAny(value string, matchers []matcher.Matcher) bool {
159 for _, m := range matchers {
160 if m.MatchString(value) {
161 return true
162 }
163 }
164 return false
165 }
166
167 func materializedContextsByPattern(plan chartengine.Plan, contextMatchers []matcher.Matcher) map[string]map[string]struct{} {
168 out := make(map[string]map[string]struct{})
169 for _, action := range plan.Actions {
170 switch v := action.(type) {
171 case chartengine.CreateChartAction:
172 if matchesAny(v.Meta.Context, contextMatchers) {
173 continue
174 }
175 if _, ok := out[v.Meta.Context]; !ok {
176 out[v.Meta.Context] = make(map[string]struct{})
177 }
178 case chartengine.CreateDimensionAction:
179 if matchesAny(v.ChartMeta.Context, contextMatchers) {
180 continue
181 }
182 dims, ok := out[v.ChartMeta.Context]
183 if !ok {
184 dims = make(map[string]struct{})
185 out[v.ChartMeta.Context] = dims
186 }
187 dims[v.Name] = struct{}{}
188 }
189 }
190 return out
191 }
192
193 func expectedTemplateCoverage(
194 templateYAML string,
195 reader metrix.Reader,
196 contextMatchers []matcher.Matcher,
197 ) (map[string][]string, error) {
198 spec, err := charttpl.DecodeYAML([]byte(templateYAML))
199 if err != nil {
200 return nil, err
201 }
202
203 byContextSet := make(map[string]map[string]struct{})
204 selectorParseCache := make(map[string]metrixselector.Selector)
205 rootContext := normalizeOptionalContextPart(spec.ContextNamespace)
206
207 for i := range spec.Groups {
208 if err := collectTemplateContexts(
209 byContextSet,
210 spec.Groups[i],
211 rootContext,
212 reader,
213 contextMatchers,
214 selectorParseCache,
215 ); err != nil {
216 return nil, err
217 }
218 }
219
220 out := make(map[string][]string, len(byContextSet))
221 for contextName, dimSet := range byContextSet {
222 dims := make([]string, 0, len(dimSet))
223 for dimName := range dimSet {
224 dims = append(dims, dimName)
225 }
226 sort.Strings(dims)
227 out[contextName] = dims
228 }
229 return out, nil
230 }
231
232 func collectTemplateContexts(
233 out map[string]map[string]struct{},
234 group charttpl.Group,
235 parentContextParts []string,
236 reader metrix.Reader,
237 contextMatchers []matcher.Matcher,
238 selectorParseCache map[string]metrixselector.Selector,
239 ) error {
240 scopeContext := append([]string(nil), parentContextParts...)
241 scopeContext = append(scopeContext, normalizeOptionalContextPart(group.ContextNamespace)...)
242
243 for _, chart := range group.Charts {
244 parts := append([]string(nil), scopeContext...)
245 parts = append(parts, strings.TrimSpace(chart.Context))
246 contextName := strings.Join(filterEmptyString(parts), ".")
247 if contextName == "" || matchesAny(contextName, contextMatchers) {
248 continue
249 }
250
251 matchedAnyDimension := false
252 dims := make(map[string]struct{})
253 for _, dim := range chart.Dimensions {
254 dimNames, matched, err := collectExpectedDimensionNames(reader, dim, selectorParseCache)
255 if err != nil {
256 return err
257 }
258 if !matched {
259 continue
260 }
261 matchedAnyDimension = true
262 for _, name := range dimNames {
263 dims[name] = struct{}{}
264 }
265 }
266 if !matchedAnyDimension {
267 continue
268 }
269
270 existingDims, ok := out[contextName]
271 if !ok {
272 existingDims = make(map[string]struct{})
273 out[contextName] = existingDims
274 }
275 for name := range dims {
276 existingDims[name] = struct{}{}
277 }
278 }
279
280 for i := range group.Groups {
281 if err := collectTemplateContexts(
282 out,
283 group.Groups[i],
284 scopeContext,
285 reader,
286 contextMatchers,
287 selectorParseCache,
288 ); err != nil {
289 return err
290 }
291 }
292 return nil
293 }
294
295 func collectExpectedDimensionNames(
296 reader metrix.Reader,
297 dim charttpl.Dimension,
298 parseCache map[string]metrixselector.Selector,
299 ) ([]string, bool, error) {
300 selectorExpr := strings.TrimSpace(dim.Selector)
301 sel, err := parseSelectorCached(selectorExpr, parseCache)
302 if err != nil {
303 return nil, false, err
304 }
305
306 explicitName := strings.TrimSpace(dim.Name)
307 nameFromLabel := strings.TrimSpace(dim.NameFromLabel)
308 names := make(map[string]struct{})
309 matched := false
310
311 reader.ForEachSeriesIdentity(func(_ metrix.SeriesIdentity, meta metrix.SeriesMeta, metricName string, labels metrix.LabelView, _ metrix.SampleValue) {
312 if err != nil {
313 return
314 }
315 if !sel.Matches(metricName, labels) {
316 return
317 }
318 matched = true
319 name, ok, nameErr := resolveExpectedDimensionName(explicitName, nameFromLabel, metricName, labels, meta)
320 if nameErr != nil {
321 err = nameErr
322 return
323 }
324 if !ok {
325 return
326 }
327 names[name] = struct{}{}
328 })
329 if err != nil {
330 return nil, false, err
331 }
332
333 out := make([]string, 0, len(names))
334 for name := range names {
335 out = append(out, name)
336 }
337 sort.Strings(out)
338 return out, matched, nil
339 }
340
341 func parseSelectorCached(selectorExpr string, cache map[string]metrixselector.Selector) (metrixselector.Selector, error) {
342 if sel, ok := cache[selectorExpr]; ok {
343 return sel, nil
344 }
345 sel, err := metrixselector.Parse(selectorExpr)
346 if err != nil {
347 return nil, fmt.Errorf("collecttest: invalid selector %q: %w", selectorExpr, err)
348 }
349 cache[selectorExpr] = sel
350 return sel, nil
351 }
352
353 func resolveExpectedDimensionName(
354 explicitName string,
355 nameFromLabel string,
356 metricName string,
357 labels metrix.LabelView,
358 meta metrix.SeriesMeta,
359 ) (string, bool, error) {
360 if explicitName != "" {
361 return explicitName, true, nil
362 }
363 if nameFromLabel != "" {
364 value, ok := labels.Get(nameFromLabel)
365 if !ok || strings.TrimSpace(value) == "" {
366 return "", false, nil
367 }
368 return value, true, nil
369 }
370
371 labelKey, ok, err := inferExpectedDimensionLabelKey(metricName, meta)
372 if err != nil {
373 return "", false, err
374 }
375 if !ok {
376 return "", false, nil
377 }
378 value, ok := labels.Get(labelKey)
379 if !ok || strings.TrimSpace(value) == "" {
380 return "", false, nil
381 }
382 return value, true, nil
383 }
384
385 func inferExpectedDimensionLabelKey(metricName string, meta metrix.SeriesMeta) (string, bool, error) {
386 switch meta.FlattenRole {
387 case metrix.FlattenRoleHistogramBucket:
388 return histogramBucketLabel, true, nil
389 case metrix.FlattenRoleSummaryQuantile:
390 return summaryQuantileLabel, true, nil
391 case metrix.FlattenRoleStateSetState:
392 if strings.TrimSpace(metricName) == "" {
393 return "", false, fmt.Errorf("collecttest: stateset inference requires metric family name")
394 }
395 return metricName, true, nil
396 case metrix.FlattenRoleHistogramCount,
397 metrix.FlattenRoleHistogramSum,
398 metrix.FlattenRoleSummaryCount,
399 metrix.FlattenRoleSummarySum:
400 return "", false, nil
401 case metrix.FlattenRoleNone:
402 return "", false, fmt.Errorf("collecttest: inferred dimension requires flattened reader metadata; use store.Read(metrix.ReadFlatten())")
403 default:
404 return "", false, fmt.Errorf("collecttest: unsupported flatten role %d for expected dimension inference", meta.FlattenRole)
405 }
406 }
407
408 func normalizeOptionalContextPart(value string) []string {
409 value = strings.TrimSpace(value)
410 if value == "" {
411 return nil
412 }
413 return []string{value}
414 }
415
416 func filterEmptyString(values []string) []string {
417 out := values[:0]
418 for _, value := range values {
419 if strings.TrimSpace(value) != "" {
420 out = append(out, value)
421 }
422 }
423 return out
424 }
425
426 func requireContextDims(t *testing.T, byContext map[string]map[string]struct{}, contextName string, dimNames []string, scopeKey string) {
427 t.Helper()
428
429 dims, ok := byContext[contextName]
430 if !ok {
431 if scopeKey != "" {
432 t.Fatalf("collecttest: missing chart context %q in host scope %q", contextName, scopeKey)
433 return
434 }
435 t.Fatalf("collecttest: missing chart context %q", contextName)
436 return
437 }
438 for _, name := range dimNames {
439 if _, exists := dims[name]; !exists {
440 if scopeKey != "" {
441 t.Fatalf("collecttest: missing dimension %q in context %q in host scope %q", name, contextName, scopeKey)
442 return
443 }
444 t.Fatalf("collecttest: missing dimension %q in context %q", name, contextName)
445 return
446 }
447 }
448 }