master
go 286 lines 8.9 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package charttpl
4
5 import (
6 "errors"
7 "fmt"
8 "slices"
9 "strings"
10 )
11
12 var (
13 validAlgorithms = []string{"absolute", "incremental"}
14 validChartTypes = []string{"line", "area", "stacked", "heatmap"}
15 )
16
17 // Validate performs semantic checks for one chart template spec.
18 func (s *Spec) Validate() error {
19 if s == nil {
20 return semErr("", "nil spec")
21 }
22 var errs []error
23 if s.Version != VersionV1 {
24 errs = append(errs, semErr("version", fmt.Sprintf("expected %q", VersionV1)))
25 }
26 if len(s.Groups) == 0 {
27 errs = append(errs, semErr("groups", "groups[] is required"))
28 }
29 errs = append(errs, validateEngine(s.Engine))
30
31 for i := range s.Groups {
32 errs = append(errs, validateGroup(s.Groups[i], fmt.Sprintf("groups[%d]", i), nil))
33 }
34 return errors.Join(errs...)
35 }
36
37 func validateGroup(group Group, path string, inheritedMetrics map[string]struct{}) error {
38 var errs []error
39 if strings.TrimSpace(group.Family) == "" {
40 errs = append(errs, semErr(path+".family", "must not be empty"))
41 }
42 errs = append(errs, validateChartDefaults(group.ChartDefaults, path))
43
44 ownMetrics := make(map[string]struct{}, len(group.Metrics))
45 for i, name := range group.Metrics {
46 name = strings.TrimSpace(name)
47 if name == "" {
48 errs = append(errs, semErr(fmt.Sprintf("%s.metrics[%d]", path, i), "metric name must not be empty"))
49 continue
50 }
51 if _, ok := ownMetrics[name]; ok {
52 errs = append(errs, semErr(fmt.Sprintf("%s.metrics[%d]", path, i), fmt.Sprintf("duplicate metric %q", name)))
53 }
54 ownMetrics[name] = struct{}{}
55 }
56
57 effective := make(map[string]struct{}, len(inheritedMetrics)+len(ownMetrics))
58 for k := range inheritedMetrics {
59 effective[k] = struct{}{}
60 }
61 for k := range ownMetrics {
62 effective[k] = struct{}{}
63 }
64
65 for i := range group.Charts {
66 errs = append(errs, validateChart(group.Charts[i], fmt.Sprintf("%s.charts[%d]", path, i), effective))
67 }
68 for i := range group.Groups {
69 errs = append(errs, validateGroup(group.Groups[i], fmt.Sprintf("%s.groups[%d]", path, i), effective))
70 }
71 return errors.Join(errs...)
72 }
73
74 func validateChartDefaults(defaults *ChartDefaults, path string) error {
75 if defaults == nil {
76 return nil
77 }
78 return errors.Join(
79 validateLabelPromotion(defaults.LabelPromoted, path+".chart_defaults.label_promotion"),
80 validateInstances(defaults.Instances, path+".chart_defaults"),
81 )
82 }
83
84 func validateChart(chart Chart, path string, effectiveMetrics map[string]struct{}) error {
85 return errors.Join(
86 validateChartCore(chart, path),
87 validateLabelPromotion(chart.LabelPromoted, path+".label_promotion"),
88 validateLifecycle(chart.Lifecycle, path),
89 validateInstances(chart.Instances, path),
90 validateDimensions(chart.Dimensions, path, effectiveMetrics),
91 )
92 }
93
94 func validateChartCore(chart Chart, path string) error {
95 var errs []error
96 if strings.TrimSpace(chart.Title) == "" {
97 errs = append(errs, semErr(path+".title", "must not be empty"))
98 }
99 if strings.TrimSpace(chart.Context) == "" {
100 errs = append(errs, semErr(path+".context", "must not be empty"))
101 }
102 if strings.TrimSpace(chart.Units) == "" {
103 errs = append(errs, semErr(path+".units", "must not be empty"))
104 }
105 if chart.Algorithm != "" && !slices.Contains(validAlgorithms, chart.Algorithm) {
106 errs = append(errs, semErr(path+".algorithm", fmt.Sprintf("must be one of %v", validAlgorithms)))
107 }
108 if chart.Type != "" && !slices.Contains(validChartTypes, chart.Type) {
109 errs = append(errs, semErr(path+".type", fmt.Sprintf("must be one of %v", validChartTypes)))
110 }
111 return errors.Join(errs...)
112 }
113
114 func validateLifecycle(lifecycle *Lifecycle, path string) error {
115 if lifecycle == nil {
116 return nil
117 }
118 var errs []error
119 if lifecycle.MaxInstances < 0 {
120 errs = append(errs, semErr(path+".lifecycle.max_instances", "must be >= 0"))
121 }
122 if lifecycle.ExpireAfterCycles < 0 {
123 errs = append(errs, semErr(path+".lifecycle.expire_after_cycles", "must be >= 0"))
124 }
125 if lifecycle.Dimensions != nil {
126 if lifecycle.Dimensions.MaxDims < 0 {
127 errs = append(errs, semErr(path+".lifecycle.dimensions.max_dims", "must be >= 0"))
128 }
129 if lifecycle.Dimensions.ExpireAfterCycles < 0 {
130 errs = append(errs, semErr(path+".lifecycle.dimensions.expire_after_cycles", "must be >= 0"))
131 }
132 }
133 return errors.Join(errs...)
134 }
135
136 func validateInstances(instances *Instances, path string) error {
137 if instances == nil {
138 return nil
139 }
140 var errs []error
141 hasPositive := false
142 if len(instances.ByLabels) == 0 {
143 return semErr(path+".instances.by_labels", "must contain at least one token when instances is set")
144 }
145
146 seen := make(map[string]struct{}, len(instances.ByLabels))
147 for i, token := range instances.ByLabels {
148 token = strings.TrimSpace(token)
149 if token == "" {
150 errs = append(errs, semErr(fmt.Sprintf("%s.instances.by_labels[%d]", path, i), "must not be empty"))
151 continue
152 }
153 switch {
154 case token == "*":
155 hasPositive = true
156 case strings.HasPrefix(token, "!"):
157 key := strings.TrimPrefix(token, "!")
158 if key == "" {
159 errs = append(errs, semErr(fmt.Sprintf("%s.instances.by_labels[%d]", path, i), "exclude token must include label key"))
160 continue
161 }
162 if strings.TrimSpace(key) != key {
163 errs = append(errs, semErr(fmt.Sprintf("%s.instances.by_labels[%d]", path, i), "exclude token must use !label_key syntax"))
164 continue
165 }
166 default:
167 hasPositive = true
168 }
169 if _, ok := seen[token]; ok {
170 errs = append(errs, semErr(fmt.Sprintf("%s.instances.by_labels[%d]", path, i), fmt.Sprintf("duplicate token %q", token)))
171 }
172 seen[token] = struct{}{}
173 }
174 if !hasPositive {
175 errs = append(errs, semErr(path+".instances.by_labels", "must include at least one positive selector ('*' or label key)"))
176 }
177 return errors.Join(errs...)
178 }
179
180 func validateDimensions(dimensions []Dimension, path string, effectiveMetrics map[string]struct{}) error {
181 if len(dimensions) == 0 {
182 return semErr(path+".dimensions", "at least one dimension is required")
183 }
184
185 var errs []error
186 seenDimNames := make(map[string]struct{}, len(dimensions))
187 for i := range dimensions {
188 d := dimensions[i]
189 selectorExpr := strings.TrimSpace(d.Selector)
190 if selectorExpr == "" {
191 errs = append(errs, semErr(fmt.Sprintf("%s.dimensions[%d].selector", path, i), "must not be empty"))
192 } else {
193 metricName, ok := selectorMetricName(selectorExpr)
194 if !ok {
195 errs = append(errs, semErr(fmt.Sprintf("%s.dimensions[%d].selector", path, i), "selector must include explicit metric name"))
196 } else if _, ok := effectiveMetrics[metricName]; !ok {
197 errs = append(errs, semErr(fmt.Sprintf("%s.dimensions[%d].selector", path, i), fmt.Sprintf("metric %q is not visible in current group scope", metricName)))
198 }
199 }
200
201 name := strings.TrimSpace(d.Name)
202 nameFrom := strings.TrimSpace(d.NameFromLabel)
203 if d.Name != "" && name == "" {
204 errs = append(errs, semErr(fmt.Sprintf("%s.dimensions[%d].name", path, i), "must not be whitespace-only"))
205 }
206 if d.NameFromLabel != "" && nameFrom == "" {
207 errs = append(errs, semErr(fmt.Sprintf("%s.dimensions[%d].name_from_label", path, i), "must not be whitespace-only"))
208 }
209 if name != "" && nameFrom != "" {
210 errs = append(errs, semErr(fmt.Sprintf("%s.dimensions[%d]", path, i), "use either name or name_from_label, not both"))
211 }
212 if name != "" {
213 if _, ok := seenDimNames[name]; ok {
214 errs = append(errs, semErr(fmt.Sprintf("%s.dimensions[%d].name", path, i), fmt.Sprintf("duplicate dimension name %q", name)))
215 } else {
216 seenDimNames[name] = struct{}{}
217 }
218 }
219 }
220 return errors.Join(errs...)
221 }
222
223 func validateEngine(engine *Engine) error {
224 if engine == nil {
225 return nil
226 }
227
228 var errs []error
229 if engine.Selector != nil {
230 for i, expr := range engine.Selector.Allow {
231 if strings.TrimSpace(expr) == "" {
232 errs = append(errs, semErr(fmt.Sprintf("engine.selector.allow[%d]", i), "must not be empty"))
233 }
234 }
235 for i, expr := range engine.Selector.Deny {
236 if strings.TrimSpace(expr) == "" {
237 errs = append(errs, semErr(fmt.Sprintf("engine.selector.deny[%d]", i), "must not be empty"))
238 }
239 }
240 }
241
242 if engine.Autogen != nil {
243 if engine.Autogen.MaxTypeIDLen < 0 {
244 errs = append(errs, semErr("engine.autogen.max_type_id_len", "must be >= 0"))
245 }
246 if engine.Autogen.MaxTypeIDLen > 0 && engine.Autogen.MaxTypeIDLen < 4 {
247 errs = append(errs, semErr("engine.autogen.max_type_id_len", "must be >= 4 when set"))
248 }
249 }
250
251 return errors.Join(errs...)
252 }
253
254 func validateLabelPromotion(labels []string, path string) error {
255 var errs []error
256 for i, label := range labels {
257 if strings.TrimSpace(label) == "" {
258 errs = append(errs, semErr(fmt.Sprintf("%s[%d]", path, i), "must not be empty"))
259 }
260 }
261 return errors.Join(errs...)
262 }
263
264 func selectorMetricName(expr string) (string, bool) {
265 s := strings.TrimSpace(expr)
266 if s == "" {
267 return "", false
268 }
269 if strings.HasPrefix(s, "{") {
270 return "", false
271 }
272 if idx := strings.IndexByte(s, '{'); idx >= 0 {
273 s = strings.TrimSpace(s[:idx])
274 }
275 if s == "" {
276 return "", false
277 }
278 return s, true
279 }
280
281 func semErr(path, reason string) error {
282 return fmt.Errorf("%w: %v", errSemanticCheck, fieldError{
283 Path: path,
284 Reason: reason,
285 })
286 }