| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package ddsnmp |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "maps" |
| 9 | "math" |
| 10 | "net" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | "text/template" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/Masterminds/sprig/v3" |
| 17 | |
| 18 | "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/oldmetrix" |
| 19 | ) |
| 20 | |
| 21 | // CompileTransforms exported for testing purposes only |
| 22 | func CompileTransforms(prof *Profile) error { |
| 23 | var errs []error |
| 24 | |
| 25 | for i := range prof.Definition.Metrics { |
| 26 | metric := &prof.Definition.Metrics[i] |
| 27 | |
| 28 | switch { |
| 29 | case metric.IsScalar(): |
| 30 | if metric.Symbol.Transform == "" { |
| 31 | continue |
| 32 | } |
| 33 | tmpl, err := compileTransform(metric.Symbol.Transform) |
| 34 | if err != nil { |
| 35 | errs = append(errs, fmt.Errorf("metric_transform parsing failed for scalar metric '%s': %v", |
| 36 | metric.Symbol.Name, err)) |
| 37 | continue |
| 38 | } |
| 39 | metric.Symbol.TransformCompiled = tmpl |
| 40 | case metric.IsColumn(): |
| 41 | for j := range metric.Symbols { |
| 42 | sym := &metric.Symbols[j] |
| 43 | if sym.Transform == "" { |
| 44 | continue |
| 45 | } |
| 46 | tmpl, err := compileTransform(sym.Transform) |
| 47 | if err != nil { |
| 48 | errs = append(errs, fmt.Errorf("metric_transform parsing failed for table '%s' metric '%s': %v", |
| 49 | metric.Table.Name, sym.Name, err)) |
| 50 | continue |
| 51 | } |
| 52 | sym.TransformCompiled = tmpl |
| 53 | } |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | if len(errs) > 0 { |
| 58 | return errors.Join(errs...) |
| 59 | |
| 60 | } |
| 61 | |
| 62 | return nil |
| 63 | } |
| 64 | |
| 65 | func compileTransform(transform string) (*template.Template, error) { |
| 66 | return template.New("metric_transform"). |
| 67 | Option("missingkey=zero"). |
| 68 | Funcs(newMetricTransformFuncMap()). |
| 69 | Parse(transform) |
| 70 | } |
| 71 | |
| 72 | func newMetricTransformFuncMap() template.FuncMap { |
| 73 | fm := sprig.TxtFuncMap() |
| 74 | |
| 75 | extra := map[string]any{ |
| 76 | "deleteTag": func(m *Metric, key string) any { |
| 77 | delete(m.Tags, key) |
| 78 | return nil |
| 79 | }, |
| 80 | "setName": func(m *Metric, name string) string { |
| 81 | m.Name = name |
| 82 | return "" |
| 83 | }, |
| 84 | "setUnit": func(m *Metric, unit string) string { |
| 85 | m.Unit = unit |
| 86 | return "" |
| 87 | }, |
| 88 | "setFamily": func(m *Metric, family string) string { |
| 89 | m.Family = family |
| 90 | return "" |
| 91 | }, |
| 92 | "setDesc": func(m *Metric, desc string) string { |
| 93 | m.Description = desc |
| 94 | return "" |
| 95 | }, |
| 96 | "setValue": func(m *Metric, value int64) string { |
| 97 | m.Value = value |
| 98 | return "" |
| 99 | }, |
| 100 | "setTag": func(m *Metric, key, value string) string { |
| 101 | if m.Tags == nil { |
| 102 | m.Tags = make(map[string]string) |
| 103 | } |
| 104 | m.Tags[key] = value |
| 105 | return "" |
| 106 | }, |
| 107 | "setMultivalue": func(m *Metric, mappings map[int64]string) string { |
| 108 | m.MultiValue = make(map[string]int64) |
| 109 | for k, v := range mappings { |
| 110 | if _, ok := m.MultiValue[v]; !ok || m.Value == k { |
| 111 | m.MultiValue[v] = oldmetrix.Bool(m.Value == k) |
| 112 | } |
| 113 | } |
| 114 | return "" |
| 115 | }, |
| 116 | "i64map": func(pairs ...any) map[int64]string { |
| 117 | m := make(map[int64]string) |
| 118 | for i := 0; i < len(pairs)-1; i += 2 { |
| 119 | key := int64(0) |
| 120 | switch k := pairs[i].(type) { |
| 121 | case int: |
| 122 | key = int64(k) |
| 123 | case int64: |
| 124 | key = k |
| 125 | case float64: |
| 126 | key = int64(k) |
| 127 | } |
| 128 | |
| 129 | if val, ok := pairs[i+1].(string); ok { |
| 130 | m[key] = val |
| 131 | } |
| 132 | } |
| 133 | return m |
| 134 | }, |
| 135 | "mask2cidr": func(mask string) string { |
| 136 | ip := net.ParseIP(mask) |
| 137 | if ip == nil { |
| 138 | return "" |
| 139 | } |
| 140 | ip = ip.To4() |
| 141 | if ip == nil { |
| 142 | return "" |
| 143 | } |
| 144 | prefixLen, bits := net.IPv4Mask(ip[0], ip[1], ip[2], ip[3]).Size() |
| 145 | if bits == 0 { |
| 146 | return "" |
| 147 | } |
| 148 | return strconv.Itoa(prefixLen) |
| 149 | }, |
| 150 | "pow": func(base float64, exp int) float64 { |
| 151 | return math.Pow(base, float64(exp)) |
| 152 | }, |
| 153 | "transformEntitySensorValue": func(m *Metric) string { |
| 154 | /* |
| 155 | transformEntitySensorValue normalizes metrics from ENTITY-SENSOR-MIB and |
| 156 | CISCO-ENTITY-SENSOR-MIB. |
| 157 | |
| 158 | Supported MIBs: |
| 159 | - ENTITY-SENSOR-MIB (RFC 3433): .1.3.6.1.2.1.99.1.1 |
| 160 | - CISCO-ENTITY-SENSOR-MIB: .1.3.6.1.4.1.9.9.91.1.1.1 |
| 161 | |
| 162 | Expected metric: |
| 163 | - entPhySensorValue / entSensorValue: .*.1.4 |
| 164 | |
| 165 | Required metric tags: |
| 166 | - sensor_type (entPhySensorType / entSensorType) |
| 167 | - sensor_scale (entPhySensorScale / entSensorScale) |
| 168 | - sensor_precision (entPhySensorPrecision / entSensorPrecision) |
| 169 | |
| 170 | What it does: |
| 171 | - Sets a descriptive name, unit, family, and description |
| 172 | - Applies scaling using sensor_scale and sensor_precision |
| 173 | - Applies value mappings for boolean/enumerated types |
| 174 | - Works across vendors (Cisco, Juniper, HPE, Dell, etc.) |
| 175 | */ |
| 176 | |
| 177 | sensorType := m.Tags["rm:sensor_type"] |
| 178 | sensorScale := m.Tags["rm:sensor_scale"] |
| 179 | sensorPrecision := m.Tags["rm:sensor_precision"] |
| 180 | |
| 181 | famPrefix := "Hardware/Sensor/" |
| 182 | config := map[string]map[string]any{ |
| 183 | "1": {"name": "unspecified", "family": "Generic", "desc": "Unspecified or vendor-specific sensor"}, |
| 184 | "2": {"name": "unknown", "family": "Unknown", "desc": "Unknown sensor type"}, |
| 185 | "3": {"name": "voltage_ac", "unit": "V", "family": "Voltage/AC", "desc": "AC voltage"}, |
| 186 | "4": {"name": "voltage_dc", "unit": "V", "family": "Voltage/DC", "desc": "DC voltage"}, |
| 187 | "5": {"name": "current", "unit": "A", "family": "Current", "desc": "Current draw"}, |
| 188 | "6": {"name": "power", "unit": "W", "family": "Power", "desc": "Power consumption"}, |
| 189 | "7": {"name": "frequency", "unit": "Hz", "family": "Frequency", "desc": "Frequency"}, |
| 190 | "8": {"name": "temperature", "unit": "Cel", "family": "Temperature", "desc": "Temperature reading"}, |
| 191 | "9": {"name": "humidity", "unit": "%", "family": "Humidity", "desc": "Relative humidity"}, |
| 192 | "10": {"name": "fan_speed", "unit": "{revolution}/min", "family": "FanSpeed", "desc": "Fan rotation speed"}, |
| 193 | "11": {"name": "airflow", "unit": "m3/min", "family": "Airflow", "desc": "Airflow in cubic meters per minute"}, |
| 194 | "12": {"name": "sensor_state", "family": "State", "desc": "Boolean sensor state", "mapping": map[int64]string{ |
| 195 | 0: "false", 1: "true", 2: "true", |
| 196 | }}, |
| 197 | "13": {"name": "special_enum", "family": "Enum", "desc": "Vendor-specific enumerated sensor"}, |
| 198 | "14": {"name": "power_dbm", "unit": "dBm", "family": "Power", "desc": "Power in decibel-milliwatts"}, |
| 199 | } |
| 200 | |
| 201 | conf, ok := config[sensorType] |
| 202 | if !ok { |
| 203 | return "" |
| 204 | } |
| 205 | |
| 206 | switch m.Name { |
| 207 | case "entPhySensorOperStatus": |
| 208 | if name, ok := conf["name"].(string); ok { |
| 209 | m.Name = m.Name + "_" + name |
| 210 | } |
| 211 | if family, ok := conf["family"].(string); ok { |
| 212 | m.Family = famPrefix + family + "/Status" |
| 213 | } |
| 214 | m.MultiValue = map[string]int64{ |
| 215 | "ok": oldmetrix.Bool(m.Value == 1), |
| 216 | "unavailable": oldmetrix.Bool(m.Value == 2), |
| 217 | "nonoperational": oldmetrix.Bool(m.Value == 3), |
| 218 | } |
| 219 | case "entPhySensorValue", "entSensorValue": |
| 220 | scaleMap := map[string]float64{ |
| 221 | "1": 1e-24, // yocto (10^-24) |
| 222 | "2": 1e-21, // zepto (10^-21) |
| 223 | "3": 1e-18, // atto (10^-18) |
| 224 | "4": 1e-15, // femto (10^-15) |
| 225 | "5": 1e-12, // pico (10^-12) |
| 226 | "6": 1e-9, // nano (10^-9) |
| 227 | "7": 1e-6, // micro (10^-6) |
| 228 | "8": 1e-3, // milli (10^-3) |
| 229 | "9": 1, // units (10^0) |
| 230 | "10": 1e3, // kilo (10^3) |
| 231 | "11": 1e6, // mega (10^6) |
| 232 | "12": 1e9, // giga (10^9) |
| 233 | "13": 1e12, // tera (10^12) |
| 234 | } |
| 235 | |
| 236 | scale := scaleMap[sensorScale] |
| 237 | if scale == 0 || scale == 1e-24 { |
| 238 | // Workaround for Cisco ASA (MIMIC) temperature bug: treat scale=1 (yocto) as units |
| 239 | scale = 1.0 |
| 240 | } |
| 241 | |
| 242 | precision := 0 |
| 243 | if p, err := strconv.Atoi(sensorPrecision); err == nil { |
| 244 | precision = p |
| 245 | } |
| 246 | |
| 247 | if name, ok := conf["name"].(string); ok { |
| 248 | m.Name = m.Name + "_" + name |
| 249 | } |
| 250 | if family, ok := conf["family"].(string); ok { |
| 251 | m.Family = famPrefix + family + "/Value" |
| 252 | } |
| 253 | if desc, ok := conf["desc"].(string); ok { |
| 254 | m.Description = desc |
| 255 | } |
| 256 | if unit, ok := conf["unit"].(string); ok { |
| 257 | m.Unit = unit |
| 258 | } |
| 259 | if mapping, ok := conf["mapping"].(map[int64]string); ok { |
| 260 | m.MultiValue = make(map[string]int64) |
| 261 | for k, v := range mapping { |
| 262 | if _, ok := m.MultiValue[v]; !ok || m.Value == k { |
| 263 | m.MultiValue[v] = oldmetrix.Bool(m.Value == k) |
| 264 | } |
| 265 | } |
| 266 | } else { |
| 267 | val := float64(m.Value) * scale |
| 268 | if precision > 0 { |
| 269 | val = val / math.Pow(10, float64(precision)) |
| 270 | } |
| 271 | m.Value = int64(val) |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | return "" |
| 276 | }, |
| 277 | "transformHrStorage": func(m *Metric) string { |
| 278 | // hrStorageType → friendly name/family |
| 279 | // https://oidref.com/1.3.6.1.2.1.25.2.1 |
| 280 | typeMap := map[string]map[string]string{ |
| 281 | "1.3.6.1.2.1.25.2.1.1": {"key": "other", "family": "System/Memory/Other", "desc": "Other memory"}, |
| 282 | "1.3.6.1.2.1.25.2.1.2": {"key": "ram", "family": "System/Memory/RAM", "desc": "Physical system memory"}, |
| 283 | "1.3.6.1.2.1.25.2.1.3": {"key": "virtual_memory", "family": "System/Memory/Swap", "desc": "Virtual memory space"}, |
| 284 | "1.3.6.1.2.1.25.2.1.4": {"key": "fixed_disk", "family": "System/Storage/Disk", "desc": "Fixed disk storage"}, |
| 285 | "1.3.6.1.2.1.25.2.1.5": {"key": "removable_disk", "family": "System/Storage/Removable", "desc": "Removable disk storage"}, |
| 286 | "1.3.6.1.2.1.25.2.1.6": {"key": "floppy", "family": "System/Storage/Removable", "desc": "Floppy disk storage"}, |
| 287 | "1.3.6.1.2.1.25.2.1.7": {"key": "cdrom", "family": "System/Storage/Removable", "desc": "CD-ROM storage"}, |
| 288 | "1.3.6.1.2.1.25.2.1.8": {"key": "ram_disk", "family": "System/Storage/RAMDisk", "desc": "RAM-based storage"}, |
| 289 | "1.3.6.1.2.1.25.2.1.9": {"key": "flash", "family": "System/Storage/Flash", "desc": "Flash-based storage"}, |
| 290 | "1.3.6.1.2.1.25.2.1.10": {"key": "network_disk", "family": "System/Storage/NetworkDisk", "desc": "Network-mounted storage"}, |
| 291 | } |
| 292 | |
| 293 | storageTypeOID := m.Tags["rm:storage_type"] |
| 294 | allocUnit, _ := strconv.ParseInt(m.Tags["rm:storage_alloc_unit"], 10, 64) |
| 295 | |
| 296 | if storageTypeOID == "" || allocUnit <= 0 || m.Value < 0 { |
| 297 | return "" |
| 298 | } |
| 299 | |
| 300 | tconf, ok := typeMap[storageTypeOID] |
| 301 | if !ok { |
| 302 | tconf = map[string]string{"key": "unknown", "family": "System/Storage/Unknown", "desc": "Unknown storage type"} |
| 303 | } |
| 304 | |
| 305 | bytesVal := m.Value * allocUnit // to bytes |
| 306 | baseKey := tconf["key"] |
| 307 | fam := tconf["family"] |
| 308 | desc := tconf["desc"] |
| 309 | |
| 310 | switch m.Name { |
| 311 | case "hrStorageSize": |
| 312 | m.Family = fam + "/Total" |
| 313 | m.Description = desc + " - Total" |
| 314 | case "hrStorageUsed": |
| 315 | m.Family = fam + "/Used" |
| 316 | m.Description = desc + " - Used" |
| 317 | default: |
| 318 | return "" |
| 319 | } |
| 320 | |
| 321 | m.Name = m.Name + "_" + baseKey |
| 322 | m.Value = bytesVal |
| 323 | |
| 324 | return "" |
| 325 | }, |
| 326 | } |
| 327 | |
| 328 | maps.Copy(fm, extra) |
| 329 | |
| 330 | return fm |
| 331 | } |
| 332 | |
| 333 | // textDateLayouts is the set of vendor-friendly date formats accepted by |
| 334 | // text_date. The list is intentionally generous: vendors that publish |
| 335 | // operational dates through SNMP rarely agree on a single textual format. |
| 336 | // Numeric slash-only dates are intentionally excluded because dd/mm/yyyy and |
| 337 | // mm/dd/yyyy are ambiguous for values like 01/02/2024. |
| 338 | var textDateLayouts = []string{ |
| 339 | time.RFC3339, |
| 340 | "2006-01-02 15:04:05", |
| 341 | "2006-01-02", |
| 342 | "Mon Jan 2 15:04:05 2006", |
| 343 | "Mon Jan 2 2006", |
| 344 | "Mon 2 January 2006", |
| 345 | "2 January 2006", |
| 346 | "January 2 2006", |
| 347 | "Jan 2 2006", |
| 348 | "Jan 2 2006 15:04:05", |
| 349 | "2 Jan 2006", |
| 350 | "2 Jan 2006 15:04:05", |
| 351 | "02 Jan 2006", |
| 352 | "02 Jan 2006 15:04:05", |
| 353 | "02Jan2006", |
| 354 | "2Jan2006", |
| 355 | } |
| 356 | |
| 357 | // ParseTextDate accepts integer- and string-encoded SNMP date shapes (epoch |
| 358 | // seconds, milliseconds, decimal no-value sentinels such as 0 and 4294967295, |
| 359 | // and the textual layouts above) and returns the equivalent unix timestamp. It |
| 360 | // is exported so the value-processor format "text_date" in |
| 361 | // ddsnmpcollector/utils.go can apply the same parsing rules. |
| 362 | func ParseTextDate(raw string) (int64, bool) { |
| 363 | return parseTextDate(raw) |
| 364 | } |
| 365 | |
| 366 | // IsTextDateNoValue reports whether raw is a vendor no-timestamp sentinel |
| 367 | // accepted by ParseTextDate. |
| 368 | func IsTextDateNoValue(raw string) bool { |
| 369 | return isTextDateNoValue(raw) |
| 370 | } |
| 371 | |
| 372 | func parseTextDate(raw string) (int64, bool) { |
| 373 | raw = strings.TrimSpace(raw) |
| 374 | if isTextDateNoValue(raw) { |
| 375 | return 0, false |
| 376 | } |
| 377 | |
| 378 | if n, err := strconv.ParseInt(raw, 10, 64); err == nil { |
| 379 | digits := strings.TrimLeft(raw, "+-") |
| 380 | switch { |
| 381 | case len(digits) >= 12: |
| 382 | return n / 1000, true |
| 383 | default: |
| 384 | return n, true |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | for _, layout := range textDateLayouts { |
| 389 | if t, err := time.Parse(layout, raw); err == nil { |
| 390 | return t.Unix(), true |
| 391 | } |
| 392 | } |
| 393 | return 0, false |
| 394 | } |
| 395 | |
| 396 | func isTextDateNoValue(raw string) bool { |
| 397 | raw = strings.TrimSpace(raw) |
| 398 | if raw == "" { |
| 399 | return true |
| 400 | } |
| 401 | |
| 402 | switch strings.ToLower(raw) { |
| 403 | case "0", "none", "n/a", "na", "perpetual", "permanent", "never", "unlimited": |
| 404 | return true |
| 405 | } |
| 406 | |
| 407 | if n, err := strconv.ParseInt(raw, 10, 64); err == nil { |
| 408 | return n <= 0 || n == 4_294_967_295 |
| 409 | } |
| 410 | |
| 411 | return false |
| 412 | } |