| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package sql |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "regexp" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/netdata/netdata/go/plugins/pkg/confopt" |
| 12 | "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth" |
| 13 | ) |
| 14 | |
| 15 | type Config struct { |
| 16 | Name string `yaml:"name,omitempty" json:"name,omitempty"` |
| 17 | UpdateEvery int `yaml:"update_every,omitempty" json:"update_every"` |
| 18 | AutoDetectionRetry int `yaml:"autodetection_retry,omitempty" json:"autodetection_retry"` |
| 19 | |
| 20 | Driver string `yaml:"driver" json:"driver"` |
| 21 | DSN string `yaml:"dsn" json:"dsn"` |
| 22 | Timeout confopt.Duration `yaml:"timeout" json:"timeout"` |
| 23 | CloudAuth cloudauth.Config `yaml:"cloud_auth" json:"cloud_auth"` |
| 24 | |
| 25 | StaticLabels map[string]string `yaml:"static_labels,omitempty" json:"static_labels"` |
| 26 | Queries []ConfigQueryDef `yaml:"queries,omitempty" json:"queries"` |
| 27 | Metrics []ConfigMetricBlock `yaml:"metrics,omitempty" json:"metrics"` |
| 28 | Functions []ConfigFunction `yaml:"functions,omitempty" json:"functions,omitempty"` |
| 29 | FunctionOnly bool `yaml:"function_only,omitempty" json:"function_only,omitempty"` |
| 30 | } |
| 31 | |
| 32 | const ( |
| 33 | defaultFunctionLimit = 100 |
| 34 | maxFunctionLimit = 10000 |
| 35 | ) |
| 36 | |
| 37 | type ConfigFunction struct { |
| 38 | ID string `yaml:"id" json:"id"` |
| 39 | Name string `yaml:"name,omitempty" json:"name,omitempty"` |
| 40 | Description string `yaml:"description,omitempty" json:"description,omitempty"` |
| 41 | Query string `yaml:"query" json:"query"` |
| 42 | Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"` |
| 43 | Limit int `yaml:"limit,omitempty" json:"limit,omitempty"` |
| 44 | DefaultSort string `yaml:"default_sort,omitempty" json:"default_sort,omitempty"` |
| 45 | DefaultSortDesc *bool `yaml:"default_sort_desc,omitempty" json:"default_sort_desc,omitempty"` |
| 46 | Columns map[string]ConfigFuncColumn `yaml:"columns,omitempty" json:"columns,omitempty"` |
| 47 | } |
| 48 | |
| 49 | type ConfigFuncColumn struct { |
| 50 | Type string `yaml:"type,omitempty" json:"type,omitempty"` |
| 51 | Units string `yaml:"units,omitempty" json:"units,omitempty"` |
| 52 | Tooltip string `yaml:"tooltip,omitempty" json:"tooltip,omitempty"` |
| 53 | Visible *bool `yaml:"visible,omitempty" json:"visible,omitempty"` |
| 54 | Sortable *bool `yaml:"sortable,omitempty" json:"sortable,omitempty"` |
| 55 | } |
| 56 | |
| 57 | func (f *ConfigFunction) derivedName() string { |
| 58 | if f.Name != "" { |
| 59 | return f.Name |
| 60 | } |
| 61 | return deriveNameFromID(f.ID) |
| 62 | } |
| 63 | |
| 64 | func deriveNameFromID(id string) string { |
| 65 | words := strings.Split(strings.ReplaceAll(id, "_", "-"), "-") |
| 66 | for i, w := range words { |
| 67 | if len(w) > 0 { |
| 68 | words[i] = strings.ToUpper(w[:1]) + w[1:] |
| 69 | } |
| 70 | } |
| 71 | return strings.Join(words, " ") |
| 72 | } |
| 73 | |
| 74 | type ( |
| 75 | ConfigQueryDef struct { |
| 76 | ID string `yaml:"id" json:"id"` |
| 77 | Query string `yaml:"query" json:"query"` |
| 78 | } |
| 79 | |
| 80 | ConfigMetricBlock struct { |
| 81 | ID string `yaml:"id" json:"id"` |
| 82 | QueryRef string `yaml:"query_ref,omitempty" json:"query_ref"` |
| 83 | Query string `yaml:"query,omitempty" json:"query"` |
| 84 | Mode string `yaml:"mode" json:"mode"` // "columns" | "kv" |
| 85 | KVMode *ConfigKVMode `yaml:"kv_mode,omitempty" json:"kv_mode"` |
| 86 | LabelsFromRow []ConfigLabelFromRow `yaml:"labels_from_row,omitempty" json:"labels_from_row"` |
| 87 | Charts []ConfigChartConfig `yaml:"charts,omitempty" json:"charts"` |
| 88 | } |
| 89 | ConfigKVMode struct { |
| 90 | NameCol string `yaml:"name_col,omitempty" json:"name_col"` |
| 91 | ValueCol string `yaml:"value_col,omitempty" json:"value_col"` |
| 92 | } |
| 93 | ConfigLabelFromRow struct { |
| 94 | Source string `yaml:"source" json:"source"` |
| 95 | Name string `yaml:"name" json:"name"` |
| 96 | //ValueMap map[string]string `yaml:"value_map" json:"value_map"` |
| 97 | } |
| 98 | ) |
| 99 | |
| 100 | type ( |
| 101 | ConfigChartConfig struct { |
| 102 | Title string `yaml:"title" json:"title"` |
| 103 | Context string `yaml:"context" json:"context"` |
| 104 | Family string `yaml:"family" json:"family"` |
| 105 | Type string `yaml:"type" json:"type"` |
| 106 | Units string `yaml:"units" json:"units"` |
| 107 | Algorithm string `yaml:"algorithm" json:"algorithm"` |
| 108 | Dims []ConfigDimConfig `yaml:"dims" json:"dims"` |
| 109 | } |
| 110 | ConfigDimConfig struct { |
| 111 | Name string `yaml:"name" json:"name"` |
| 112 | Source string `yaml:"source" json:"source"` |
| 113 | StatusWhen *ConfigStatusWhen `yaml:"status_when,omitempty" json:"status_when,omitempty"` |
| 114 | } |
| 115 | ConfigStatusWhen struct { |
| 116 | Equals string `yaml:"equals,omitempty" json:"equals,omitempty"` |
| 117 | In []string `yaml:"in,omitempty" json:"in,omitempty"` |
| 118 | Match string `yaml:"match,omitempty" json:"match,omitempty"` |
| 119 | |
| 120 | re *regexp.Regexp |
| 121 | } |
| 122 | ) |
| 123 | |
| 124 | func (c *Collector) validateConfig() error { |
| 125 | var errs []error |
| 126 | |
| 127 | if c.Driver == "" { |
| 128 | errs = append(errs, errors.New("driver required")) |
| 129 | } else if !supportedDrivers[c.Driver] { |
| 130 | errs = append(errs, fmt.Errorf("unsupported driver %q", c.Driver)) |
| 131 | } |
| 132 | if c.DSN == "" { |
| 133 | errs = append(errs, errors.New("dsn required")) |
| 134 | } |
| 135 | if err := c.CloudAuth.Validate(); err != nil { |
| 136 | errs = append(errs, err) |
| 137 | } |
| 138 | if c.CloudAuth.IsEnabled() { |
| 139 | switch c.Driver { |
| 140 | case "pgx", "sqlserver", "azuresql": |
| 141 | default: |
| 142 | errs = append(errs, fmt.Errorf("cloud_auth.provider %q is supported only for drivers %q, %q and %q", |
| 143 | c.CloudAuth.ProviderName(), |
| 144 | "pgx", "sqlserver", "azuresql")) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | if c.FunctionOnly { |
| 149 | if len(c.Metrics) > 0 { |
| 150 | errs = append(errs, errors.New("function_only is set but metrics are defined")) |
| 151 | } |
| 152 | if len(c.Functions) == 0 { |
| 153 | errs = append(errs, errors.New("function_only is set but no functions defined")) |
| 154 | } |
| 155 | } else { |
| 156 | if len(c.Metrics) == 0 { |
| 157 | errs = append(errs, errors.New("metrics required (or set function_only: true)")) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | queryIdx := map[string]bool{} |
| 162 | for i := range c.Queries { |
| 163 | errs = append(errs, c.Queries[i].validate(i, queryIdx)...) |
| 164 | } |
| 165 | |
| 166 | for i := range c.Metrics { |
| 167 | errs = append(errs, c.Metrics[i].validate(i, queryIdx)...) |
| 168 | } |
| 169 | |
| 170 | funcIdx := map[string]bool{} |
| 171 | for i := range c.Functions { |
| 172 | errs = append(errs, c.Functions[i].validate(i, funcIdx)...) |
| 173 | } |
| 174 | |
| 175 | return errors.Join(errs...) |
| 176 | } |
| 177 | |
| 178 | func (f *ConfigFunction) validate(idx int, seen map[string]bool) []error { |
| 179 | var errs []error |
| 180 | fidx := idx + 1 |
| 181 | |
| 182 | if f.ID == "" { |
| 183 | errs = append(errs, fmt.Errorf("functions[%d] missing id", fidx)) |
| 184 | } |
| 185 | if f.Query == "" { |
| 186 | errs = append(errs, fmt.Errorf("functions[%d] missing query", fidx)) |
| 187 | } |
| 188 | |
| 189 | if f.ID != "" { |
| 190 | if strings.Contains(f.ID, ":") { |
| 191 | errs = append(errs, fmt.Errorf("functions[%d] id %q cannot contain ':'", fidx, f.ID)) |
| 192 | } |
| 193 | if _, dup := seen[f.ID]; dup { |
| 194 | errs = append(errs, fmt.Errorf("functions[%d] duplicate id %q", fidx, f.ID)) |
| 195 | } |
| 196 | seen[f.ID] = true |
| 197 | } |
| 198 | |
| 199 | if f.Limit < 0 { |
| 200 | errs = append(errs, fmt.Errorf("functions[%d] limit cannot be negative", fidx)) |
| 201 | } |
| 202 | if f.Limit > maxFunctionLimit { |
| 203 | errs = append(errs, fmt.Errorf("functions[%d] limit exceeds maximum (%d)", fidx, maxFunctionLimit)) |
| 204 | } |
| 205 | if f.Timeout.Duration() < 0 { |
| 206 | errs = append(errs, fmt.Errorf("functions[%d] timeout cannot be negative", fidx)) |
| 207 | } |
| 208 | |
| 209 | validTypes := map[string]bool{ |
| 210 | "string": true, "integer": true, "float": true, |
| 211 | "boolean": true, "duration": true, "timestamp": true, |
| 212 | } |
| 213 | for colName, col := range f.Columns { |
| 214 | if col.Type != "" && !validTypes[col.Type] { |
| 215 | errs = append(errs, fmt.Errorf("functions[%d] column %q invalid type %q", fidx, colName, col.Type)) |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | return errs |
| 220 | } |
| 221 | |
| 222 | // ---- Per-struct validation helpers ---- |
| 223 | |
| 224 | func (q *ConfigQueryDef) validate(idx int, seen map[string]bool) []error { |
| 225 | var errs []error |
| 226 | qidx := idx + 1 |
| 227 | |
| 228 | if q.ID == "" { |
| 229 | errs = append(errs, fmt.Errorf("queries[%d] missing id", qidx)) |
| 230 | } |
| 231 | if q.Query == "" { |
| 232 | errs = append(errs, fmt.Errorf("queries[%d] missing query", qidx)) |
| 233 | } |
| 234 | |
| 235 | if q.ID != "" { |
| 236 | if _, dup := seen[q.ID]; dup { |
| 237 | errs = append(errs, fmt.Errorf("queries[%d] duplicate id %q", qidx, q.ID)) |
| 238 | } |
| 239 | seen[q.ID] = true |
| 240 | } |
| 241 | |
| 242 | return errs |
| 243 | } |
| 244 | |
| 245 | func (m *ConfigMetricBlock) validate(idx int, queryIdx map[string]bool) []error { |
| 246 | var errs []error |
| 247 | midx := idx + 1 |
| 248 | |
| 249 | if m.ID == "" { |
| 250 | errs = append(errs, fmt.Errorf("metrics[%d] missing id", midx)) |
| 251 | } |
| 252 | |
| 253 | hasRef := strings.TrimSpace(m.QueryRef) != "" |
| 254 | hasInline := strings.TrimSpace(m.Query) != "" |
| 255 | switch { |
| 256 | case hasRef && hasInline: |
| 257 | errs = append(errs, fmt.Errorf("metrics[%d] must set exactly one of query_ref or query, not both", midx)) |
| 258 | case !hasRef && !hasInline: |
| 259 | errs = append(errs, fmt.Errorf("metrics[%d] must set exactly one of query_ref or query", midx)) |
| 260 | case hasRef: |
| 261 | if _, ok := queryIdx[m.QueryRef]; !ok { |
| 262 | errs = append(errs, fmt.Errorf("metrics[%d] query_ref %q not found in queries", midx, m.QueryRef)) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | mode := strings.ToLower(strings.TrimSpace(m.Mode)) |
| 267 | switch mode { |
| 268 | case "kv": |
| 269 | if m.KVMode == nil { |
| 270 | errs = append(errs, fmt.Errorf("metrics[%d] kv mode requires kv_mode to be defined", midx)) |
| 271 | } else { |
| 272 | if strings.TrimSpace(m.KVMode.NameCol) == "" { |
| 273 | errs = append(errs, fmt.Errorf("metrics[%d] kv_mode.name_col is required in kv mode", midx)) |
| 274 | } |
| 275 | if strings.TrimSpace(m.KVMode.ValueCol) == "" { |
| 276 | errs = append(errs, fmt.Errorf("metrics[%d] kv_mode.value_col is required in kv mode", midx)) |
| 277 | } |
| 278 | if strings.EqualFold(m.KVMode.NameCol, m.KVMode.ValueCol) { |
| 279 | errs = append(errs, fmt.Errorf("metrics[%d] kv_mode.name_col must differ from kv_mode.value_col", midx)) |
| 280 | } |
| 281 | } |
| 282 | case "columns", "": |
| 283 | default: |
| 284 | errs = append(errs, fmt.Errorf("metrics[%d] invalid mode %q (expected: columns|kv)", midx, m.Mode)) |
| 285 | } |
| 286 | |
| 287 | labelCols := map[string]bool{} |
| 288 | for j := range m.LabelsFromRow { |
| 289 | errs = append(errs, m.LabelsFromRow[j].validate(midx, j, labelCols)...) |
| 290 | } |
| 291 | |
| 292 | if len(m.Charts) == 0 { |
| 293 | errs = append(errs, fmt.Errorf("metrics[%d].%s missing charts", midx, m.ID)) |
| 294 | } |
| 295 | |
| 296 | for k := range m.Charts { |
| 297 | errs = append(errs, m.Charts[k].validate(idx, m.ID, k, mode, labelCols, m.KVMode)...) |
| 298 | } |
| 299 | |
| 300 | return errs |
| 301 | } |
| 302 | |
| 303 | func (lf *ConfigLabelFromRow) validate(metricIdx, lfIdx int, labelCols map[string]bool) []error { |
| 304 | var errs []error |
| 305 | midx := metricIdx + 1 |
| 306 | lidx := lfIdx + 1 |
| 307 | |
| 308 | if lf.Source == "" { |
| 309 | errs = append(errs, fmt.Errorf("metrics[%d].labels_from_row[%d] missing source", midx, lidx)) |
| 310 | } |
| 311 | if lf.Name == "" { |
| 312 | errs = append(errs, fmt.Errorf("metrics[%d].labels_from_row[%d] missing name", midx, lidx)) |
| 313 | } |
| 314 | if lf.Source != "" { |
| 315 | labelCols[strings.ToLower(lf.Source)] = true |
| 316 | } |
| 317 | |
| 318 | return errs |
| 319 | } |
| 320 | |
| 321 | func (ch *ConfigChartConfig) validate(metricIdx int, metricID string, chartIdx int, mode string, labelCols map[string]bool, kv *ConfigKVMode) []error { |
| 322 | var errs []error |
| 323 | midx := metricIdx + 1 |
| 324 | cidx := chartIdx + 1 |
| 325 | |
| 326 | if strings.TrimSpace(ch.Context) == "" { |
| 327 | errs = append(errs, fmt.Errorf("metrics[%d].charts[%d] missing context", midx, cidx)) |
| 328 | } |
| 329 | if strings.TrimSpace(ch.Title) == "" { |
| 330 | errs = append(errs, fmt.Errorf("metrics[%d].charts[%d] missing title", midx, cidx)) |
| 331 | } |
| 332 | if strings.TrimSpace(ch.Family) == "" { |
| 333 | errs = append(errs, fmt.Errorf("metrics[%d].charts[%d] missing family", midx, cidx)) |
| 334 | } |
| 335 | if strings.TrimSpace(ch.Units) == "" { |
| 336 | errs = append(errs, fmt.Errorf("metrics[%d].charts[%d] missing units", midx, cidx)) |
| 337 | } |
| 338 | |
| 339 | seenDims := map[string]bool{} |
| 340 | for d := range ch.Dims { |
| 341 | errs = append(errs, ch.Dims[d].validate(metricIdx, metricID, chartIdx, d, mode, labelCols, kv, seenDims)...) |
| 342 | } |
| 343 | |
| 344 | return errs |
| 345 | } |
| 346 | |
| 347 | func (dm *ConfigDimConfig) validate( |
| 348 | metricIdx int, |
| 349 | metricID string, |
| 350 | chartIdx int, |
| 351 | dimIdx int, |
| 352 | mode string, |
| 353 | labelCols map[string]bool, |
| 354 | kv *ConfigKVMode, |
| 355 | seenDims map[string]bool, |
| 356 | ) []error { |
| 357 | var errs []error |
| 358 | midx := metricIdx + 1 |
| 359 | cidx := chartIdx + 1 |
| 360 | didx := dimIdx + 1 |
| 361 | |
| 362 | if strings.TrimSpace(dm.Name) == "" { |
| 363 | errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d] missing name", midx, cidx, didx)) |
| 364 | } |
| 365 | if strings.TrimSpace(dm.Source) == "" { |
| 366 | errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d] missing source", midx, cidx, didx)) |
| 367 | } |
| 368 | |
| 369 | // unique dim names (case-insensitive) |
| 370 | if dm.Name != "" { |
| 371 | key := strings.ToLower(dm.Name) |
| 372 | if _, ok := seenDims[key]; ok { |
| 373 | errs = append(errs, fmt.Errorf("metrics[%d].charts[%d] duplicate dim name %q", midx, cidx, dm.Name)) |
| 374 | } else { |
| 375 | seenDims[key] = true |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | if dm.StatusWhen != nil { |
| 380 | errs = append(errs, dm.StatusWhen.validate(metricIdx, metricID, chartIdx, dimIdx)...) |
| 381 | } |
| 382 | |
| 383 | switch mode { |
| 384 | case "columns": |
| 385 | if _, clash := labelCols[strings.ToLower(dm.Source)]; clash { |
| 386 | errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d] source %q conflicts with labels_from_row source", midx, cidx, didx, dm.Source)) |
| 387 | } |
| 388 | case "kv": |
| 389 | // In kv mode, dim.source must be a KEY name (not a column). |
| 390 | if kv != nil && strings.EqualFold(dm.Source, kv.ValueCol) { |
| 391 | errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d] source %q equals kv_mode.value_col; dim.source must be a KEY name", midx, cidx, didx, dm.Source)) |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | return errs |
| 396 | } |
| 397 | |
| 398 | func (sw *ConfigStatusWhen) validate(metricIdx int, metricID string, chartIdx, dimIdx int) []error { |
| 399 | var errs []error |
| 400 | midx := metricIdx + 1 |
| 401 | cidx := chartIdx + 1 |
| 402 | didx := dimIdx + 1 |
| 403 | |
| 404 | count := 0 |
| 405 | if sw.Equals != "" { |
| 406 | count++ |
| 407 | } |
| 408 | if len(sw.In) > 0 { |
| 409 | count++ |
| 410 | } |
| 411 | if strings.TrimSpace(sw.Match) != "" { |
| 412 | re, err := regexp.Compile(sw.Match) |
| 413 | if err != nil { |
| 414 | errs = append(errs, fmt.Errorf("invalid regex in status_when.match for metric %q: %w", metricID, err)) |
| 415 | } else { |
| 416 | // store compiled regex for later use |
| 417 | sw.re = re |
| 418 | } |
| 419 | count++ |
| 420 | } |
| 421 | |
| 422 | if count == 0 { |
| 423 | errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d].status_when must have exactly one of equals|in|match", midx, cidx, didx)) |
| 424 | } |
| 425 | if count > 1 { |
| 426 | errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d].status_when must not set multiple selectors", midx, cidx, didx)) |
| 427 | } |
| 428 | |
| 429 | return errs |
| 430 | } |