diff --git a/pkg/snclient/check_drivesize.go b/pkg/snclient/check_drivesize.go index 146fc3df..ab0127cf 100644 --- a/pkg/snclient/check_drivesize.go +++ b/pkg/snclient/check_drivesize.go @@ -298,43 +298,92 @@ func (l *CheckDrivesize) isExcluded(drive map[string]string, excludes []string) return false } -func (l *CheckDrivesize) addMetrics(drive string, check *CheckData, usage *disk.UsageStat, magic float64) { +// The keyword after transformation matches the metric label added for that attribute +// So for single conditions without a group, these keywords will be translated +// They will then be processed during metric evaluation phase and possibly raise warning/critical/unknown. +func (l *CheckDrivesize) transformDrivePctMetrics(driveName string, check *CheckData) { + for _, attribute := range check.attributes { + if !strings.HasSuffix(attribute.name, "_pct") { + continue + } + + if attribute.unit != UPercent { + continue + } + + cut, _ := strings.CutSuffix(attribute.name, "_pct") + + // From: + // To: % + check.transformKeywordsUsingAttributes(`%[2]s %[1]s`, `%[2]s %[3]s %%`, []string{attribute.name}, driveName, cut) + } +} + +// If a condition akin to metric label is added, specialized for that drive +// Go through the conditions again, and disable generic versions of these +func (l *CheckDrivesize) disableGenerallizedConditionsForDrive(driveName string, entry map[string]string, check *CheckData) { + for _, attribute := range check.attributes { + if !strings.HasSuffix(attribute.name, "_pct") { + continue + } + + if attribute.unit != UPercent { + continue + } + + cut, _ := strings.CutSuffix(attribute.name, "_pct") + + // specialized: % generalized: + check.disableGenerallizedConditionsUsingAttributes(entry, `%[2]s %[3]s %%`, `%[1]s`, []string{attribute.name}, driveName, cut) + + // specialized: % generalized: % + check.disableGenerallizedConditionsUsingAttributes(entry, `%[2]s %[3]s %%`, `%[1]s %%`, []string{attribute.name}, driveName, cut) + + // specialized: % generalized: + check.disableGenerallizedConditionsUsingAttributes(entry, `%[2]s %[3]s %%`, `%[3]s`, []string{attribute.name}, driveName, cut) + } +} + +func (l *CheckDrivesize) addMetrics(drive map[string]string, check *CheckData, usage *disk.UsageStat, magic float64) { total := usage.Total if !l.freespaceIgnoreReserved { total = usage.Used + usage.Free // use this total instead of usage.Total to account in the root reserved space } - if check.HasThreshold("free") || check.HasThreshold("free_pct") || check.HasThreshold("free_bytes") { + driveFreeMetricLabel := fmt.Sprintf("%s free %%", drive["drive"]) + if check.HasThreshold(driveFreeMetricLabel) || check.HasThreshold("free") || check.HasThreshold("free_pct") || check.HasThreshold("free_bytes") { check.warnThreshold = check.TransformMultipleKeywords([]string{"free_pct", "free_bytes"}, "free", check.warnThreshold) check.critThreshold = check.TransformMultipleKeywords([]string{"free_pct", "free_bytes"}, "free", check.critThreshold) - check.AddBytePercentMetrics("free", drive+" free", magic*float64(usage.Free), magic*float64(total)) + perfLabel := fmt.Sprintf("%s free", drive["drive"]) + check.AddBytePercentMetrics("free", perfLabel, magic*float64(usage.Free), magic*float64(total), drive) + check.processMetricsWithSpecializedKeyword("drive", perfLabel, drive) } - // convert ' used_pct' keywords in conditions to ' used %' as that matches the metric name - convertDriveUsagePctMetric1 := fmt.Sprintf("%s used_pct", drive) - // metrics are normally added if the operand is simply 'used' , 'used_pct' , 'used_bytes' etc. and do not have a drive prefix - // detect conditions where the operand is named ' used %', this is the default way snclient names percent usage metrics. - // if there is a condition using that as an operand, add usage metrics for that drive as well. during the metrics condition checking, they will take effect. - // this helps to check usage metrics specific to drives. - driveUsagePctMetric := fmt.Sprintf("%s used %%", drive) - - check.warnThreshold = check.TransformMultipleKeywords([]string{convertDriveUsagePctMetric1}, driveUsagePctMetric, check.warnThreshold) - check.critThreshold = check.TransformMultipleKeywords([]string{convertDriveUsagePctMetric1}, driveUsagePctMetric, check.critThreshold) - - if check.HasThreshold(driveUsagePctMetric) || check.HasThreshold("used") || check.HasThreshold("used_pct") || check.HasThreshold("used_bytes") { + driveUsagePctMetricLabel := fmt.Sprintf("%s used %%", drive["drive"]) + if check.HasThreshold(driveUsagePctMetricLabel) || check.HasThreshold("used") || check.HasThreshold("used_pct") || check.HasThreshold("used_bytes") { check.warnThreshold = check.TransformMultipleKeywords([]string{"used_pct", "used_bytes"}, "used", check.warnThreshold) check.critThreshold = check.TransformMultipleKeywords([]string{"used_pct", "used_bytes"}, "used", check.critThreshold) - check.AddBytePercentMetrics("used", drive+" used", magic*float64(usage.Used), magic*float64(total)) + perfLabel := fmt.Sprintf("%s used", drive["drive"]) + check.AddBytePercentMetrics("used", perfLabel, magic*float64(usage.Used), magic*float64(total), drive) + check.processMetricsWithSpecializedKeyword("drive", perfLabel, drive) } - if check.HasThreshold("inodes") || check.HasThreshold("inodes_used") || check.HasThreshold("inodes_used_pct") { - check.warnThreshold = check.TransformMultipleKeywords([]string{"inodes_used_pct", "inodes_used"}, "inodes", check.warnThreshold) - check.critThreshold = check.TransformMultipleKeywords([]string{"inodes_used_pct", "inodes_used"}, "inodes", check.critThreshold) - check.AddPercentMetrics("inodes", drive+" inodes", float64(usage.InodesUsed), float64(usage.InodesTotal)) + + driveInodesUsedPctMetricLabel := fmt.Sprintf("%s inodes_used %%", drive["drive"]) + if check.HasThreshold(driveInodesUsedPctMetricLabel) || check.HasThreshold("inodes") || check.HasThreshold("inodes_used") || check.HasThreshold("inodes_used_pct") { + check.warnThreshold = check.TransformMultipleKeywords([]string{"inodes_used_pct"}, "inodes_used", check.warnThreshold) + check.critThreshold = check.TransformMultipleKeywords([]string{"inodes_used_pct"}, "inodes_used", check.critThreshold) + perfLabel := fmt.Sprintf("%s inodes_used", drive["drive"]) + check.AddPercentMetrics("inodes_used", perfLabel, float64(usage.InodesUsed), float64(usage.InodesTotal), drive) + check.processMetricsWithSpecializedKeyword("drive", perfLabel, drive) } - if check.HasThreshold("inodes_free") || check.HasThreshold("inodes_free_pct") { + + driveInodesFreePctMetricLabel := fmt.Sprintf("%s inodes_free %%", drive["drive"]) + if check.HasThreshold(driveInodesFreePctMetricLabel) || check.HasThreshold("inodes_free") || check.HasThreshold("inodes_free_pct") { check.warnThreshold = check.TransformMultipleKeywords([]string{"inodes_free_pct"}, "inodes_free", check.warnThreshold) check.critThreshold = check.TransformMultipleKeywords([]string{"inodes_free_pct"}, "inodes_free", check.critThreshold) - check.AddPercentMetrics("inodes_free", drive+" inodes free", float64(usage.InodesFree), float64(usage.InodesTotal)) + perfLabel := fmt.Sprintf("%s inodes_free", drive["drive"]) + check.AddPercentMetrics("inodes_free", perfLabel, float64(usage.InodesFree), float64(usage.InodesTotal), drive) + check.processMetricsWithSpecializedKeyword("drive", perfLabel, drive) } } @@ -392,10 +441,10 @@ func (l *CheckDrivesize) addTotal(check *CheckData) { } if check.HasThreshold("free") || check.HasThreshold("free_bytes") { - check.AddBytePercentMetrics("free", drive["drive"]+" free", float64(free), float64(total)) + check.AddBytePercentMetrics("free", drive["drive"]+" free", float64(free), float64(total), map[string]string{}) } if check.HasThreshold("used") || check.HasThreshold("used_bytes") { - check.AddBytePercentMetrics("used", drive["drive"]+" used", float64(used), float64(total)) + check.AddBytePercentMetrics("used", drive["drive"]+" used", float64(used), float64(total), map[string]string{}) } } @@ -452,7 +501,11 @@ func (l *CheckDrivesize) addDriveSizeDetails(check *CheckData, drive map[string] return } - l.addMetrics(drive["drive"], check, usage, magic) + l.transformDrivePctMetrics(drive["drive"], check) + + l.disableGenerallizedConditionsForDrive(drive["drive"], drive, check) + + l.addMetrics(drive, check, usage, magic) } func (l *CheckDrivesize) getFlagNames(drive map[string]string) []string { diff --git a/pkg/snclient/check_drivesize_test.go b/pkg/snclient/check_drivesize_test.go index 520291d9..9744fea8 100644 --- a/pkg/snclient/check_drivesize_test.go +++ b/pkg/snclient/check_drivesize_test.go @@ -49,7 +49,7 @@ func TestCheckDrivesize(t *testing.T) { res = snc.RunCheck("check_drivesize", []string{"warn=inodes>100%", "crit=inodes>100%", "folder=" + tmpFolder}) assert.Equalf(t, CheckExitOK, res.State, "state OK") assert.Contains(t, string(res.BuildPluginOutput()), `OK - All 1 drive`, "output matches") - assert.Contains(t, string(res.BuildPluginOutput()), `'`+tmpFolder+` inodes'=`, "output matches") + assert.Contains(t, string(res.BuildPluginOutput()), `'`+tmpFolder+` inodes_used %'=`, "output matches") StopTestAgent(t, snc) } diff --git a/pkg/snclient/check_memory.go b/pkg/snclient/check_memory.go index 6a7a8353..e48ffc7e 100644 --- a/pkg/snclient/check_memory.go +++ b/pkg/snclient/check_memory.go @@ -168,11 +168,11 @@ func (l *CheckMemory) addMemType(check *CheckData, name string, used, free, tota if check.HasThreshold("free") || check.HasThreshold("free_pct") { check.warnThreshold = check.TransformMultipleKeywords([]string{"free_pct"}, "free", check.warnThreshold) check.critThreshold = check.TransformMultipleKeywords([]string{"free_pct"}, "free", check.critThreshold) - check.AddBytePercentMetrics("free", name+"_free", float64(free), float64(total)) + check.AddBytePercentMetrics("free", name+"_free", float64(free), float64(total), entry) } if check.HasThreshold("used") || check.HasThreshold("used_pct") { check.warnThreshold = check.TransformMultipleKeywords([]string{"used_pct"}, "used", check.warnThreshold) check.critThreshold = check.TransformMultipleKeywords([]string{"used_pct"}, "used", check.critThreshold) - check.AddBytePercentMetrics("used", name, float64(used), float64(total)) + check.AddBytePercentMetrics("used", name, float64(used), float64(total), entry) } } diff --git a/pkg/snclient/check_pagefile_windows.go b/pkg/snclient/check_pagefile_windows.go index 271d23b0..7b8cab2b 100644 --- a/pkg/snclient/check_pagefile_windows.go +++ b/pkg/snclient/check_pagefile_windows.go @@ -85,21 +85,21 @@ func (l *CheckPagefile) addPagefile(check *CheckData, name string, data map[stri check.listData = append(check.listData, entry) if check.HasThreshold("free") { - check.AddBytePercentMetrics("free", name, float64(data["AllocatedBaseSize"]-data["CurrentUsage"]), float64(data["AllocatedBaseSize"])) + check.AddBytePercentMetrics("free", name, float64(data["AllocatedBaseSize"]-data["CurrentUsage"]), float64(data["AllocatedBaseSize"]), entry) } if check.HasThreshold("used") { - check.AddBytePercentMetrics("used", name, float64(data["CurrentUsage"]), float64(data["AllocatedBaseSize"])) + check.AddBytePercentMetrics("used", name, float64(data["CurrentUsage"]), float64(data["AllocatedBaseSize"]), entry) } if check.HasThreshold("peak") { - check.AddBytePercentMetrics("peak", name, float64(data["PeakUsage"]), float64(data["AllocatedBaseSize"])) + check.AddBytePercentMetrics("peak", name, float64(data["PeakUsage"]), float64(data["AllocatedBaseSize"]), entry) } if check.HasThreshold("free_pct") { - check.AddPercentMetrics("free_pct", name, float64(data["AllocatedBaseSize"]-data["CurrentUsage"]), float64(data["AllocatedBaseSize"])) + check.AddPercentMetrics("free_pct", name, float64(data["AllocatedBaseSize"]-data["CurrentUsage"]), float64(data["AllocatedBaseSize"]), entry) } if check.HasThreshold("used_pct") { - check.AddPercentMetrics("used_pct", name, float64(data["AllocatedBaseSize"]-data["CurrentUsage"]), float64(data["AllocatedBaseSize"])) + check.AddPercentMetrics("used_pct", name, float64(data["AllocatedBaseSize"]-data["CurrentUsage"]), float64(data["AllocatedBaseSize"]), entry) } if check.HasThreshold("peak_pct") { - check.AddPercentMetrics("used_pct", name, float64(data["AllocatedBaseSize"]-data["PeakUsage"]), float64(data["AllocatedBaseSize"])) + check.AddPercentMetrics("used_pct", name, float64(data["AllocatedBaseSize"]-data["PeakUsage"]), float64(data["AllocatedBaseSize"]), entry) } } diff --git a/pkg/snclient/checkdata.go b/pkg/snclient/checkdata.go index 2948ed35..ae50a287 100644 --- a/pkg/snclient/checkdata.go +++ b/pkg/snclient/checkdata.go @@ -158,7 +158,10 @@ func (cd *CheckData) Finalize() (*CheckResult, error) { log.Debugf("condition critical: %s", cd.critThreshold.String()) log.Debugf("condition ok: %s", cd.okThreshold.String()) // Run thresholds once on cd.details. This is done separately than metrics or entries - // This can possibly set a value to cd.details[_state] + // cd.details are of type map[string]string, + // same as elements of the slice cd.listData, but there is only one per check + // This can possibly set a value to cd.details[_state] , influencing check state + log.Tracef("checking warning, critical, and ok thresholds on check details") cd.Check(cd.details, cd.warnThreshold, cd.critThreshold, cd.okThreshold) log.Tracef("details:") logTraceASCIIMap(cd.details) @@ -209,7 +212,8 @@ func (cd *CheckData) finalizeOutput() (*CheckResult, error) { } // each entry in the list data is individually checked - // This may set "_state" of each entry + // This can possibly set "_state" of each entry, influencing the final state + log.Tracef("checking warning, critical, and ok thresholds on a check entry") cd.Check(entry, cd.warnThreshold, cd.critThreshold, cd.okThreshold) } @@ -230,11 +234,12 @@ func (cd *CheckData) finalizeOutput() (*CheckResult, error) { } cd.result.ApplyPerfSyntax(cd.perfSyntax, cd.timezone) - // Run a separate check on the macros + log.Tracef("checking warning, critical, and ok thresholds on check macros") cd.Check(finalMacros, cd.warnThreshold, cd.critThreshold, cd.okThreshold) cd.setStateFromMaps(finalMacros) // Metrics are checked last, which also sets the final state + log.Tracef("checking warning, critical, and ok thresholds on check metrics") cd.CheckMetrics(cd.warnThreshold, cd.critThreshold, cd.okThreshold) switch { @@ -458,21 +463,21 @@ func (cd *CheckData) Check(data map[string]string, warnCond, critCond, okCond Co for i := range warnCond { if res, ok := warnCond[i].Match(data); res && ok { - log.Debugf("This data '%s' matched the WARNING Condition", warnCond[i].original) + log.Debugf("The given data matched the WARNING condition: '%s' ", warnCond[i].DetailedString()) data["_state"] = fmt.Sprintf("%d", CheckExitWarning) } } for i := range critCond { if res, ok := critCond[i].Match(data); res && ok { - log.Debugf("This data '%s' matched the CRITICAL Condition", critCond[i].original) + log.Debugf("This given data matched the CRITICAL condition: '%s' ", critCond[i].DetailedString()) data["_state"] = fmt.Sprintf("%d", CheckExitCritical) } } for i := range okCond { if res, ok := okCond[i].Match(data); res && ok { - log.Debugf("This data '%s' matched the OK Condition", okCond[i].original) + log.Debugf("This given data matched the OK condition: '%s' ", okCond[i].DetailedString()) data["_state"] = fmt.Sprintf("%d", CheckExitOK) } } @@ -1293,7 +1298,7 @@ func (cd *CheckData) ExpandMetricMacros(srcThreshold ConditionList, data map[str return replaced } -func (cd *CheckData) AddBytePercentMetrics(threshold, perfLabel string, val, total float64) { +func (cd *CheckData) AddBytePercentMetrics(threshold, perfLabel string, val, total float64, entry map[string]string) { percent := float64(0) if threshold == "used" { percent = 100 @@ -1312,6 +1317,7 @@ func (cd *CheckData) AddBytePercentMetrics(threshold, perfLabel string, val, tot Critical: cd.TransformThreshold(cd.critThreshold, threshold, perfLabel, "%", "B", total), Min: &Zero, Max: &total, + Entry: entry, }, &CheckMetric{ Name: pctName, @@ -1321,11 +1327,12 @@ func (cd *CheckData) AddBytePercentMetrics(threshold, perfLabel string, val, tot Critical: cd.TransformThreshold(cd.critThreshold, threshold, pctName, "B", "%", total), Min: &Zero, Max: &Hundred, + Entry: entry, }, ) } -func (cd *CheckData) AddPercentMetrics(threshold, perfLabel string, val, total float64) { +func (cd *CheckData) AddPercentMetrics(threshold, perfLabel string, val, total float64, entry map[string]string) { percent := float64(0) if strings.Contains(threshold, "used") { percent = 100 @@ -1335,10 +1342,11 @@ func (cd *CheckData) AddPercentMetrics(threshold, perfLabel string, val, total f if total > 0 { percent = val * 100 / total } + pctName := perfLabel + " %" cd.result.Metrics = append( cd.result.Metrics, &CheckMetric{ - Name: perfLabel, + Name: pctName, ThresholdName: threshold, Unit: "%", Value: utils.ToPrecision(percent, 1), @@ -1346,10 +1354,64 @@ func (cd *CheckData) AddPercentMetrics(threshold, perfLabel string, val, total f Critical: cd.critThreshold, Min: &Zero, Max: &Hundred, + Entry: entry, }, ) } +// processMetricsWithSpecializedKeyword sets the Entry reference and filters Warning/Critical thresholds of the Metric object +// the thresholds will be filtered using the special keyword. refer to that function for more details, as it uses a heurisic to determine how to filter +// +//nolint:unparam // this is only used in check_drivesize for now, so keyword is always "drive" +func (cd *CheckData) processMetricsWithSpecializedKeyword(keyword, metricName string, entry map[string]string) { + for _, metric := range cd.result.Metrics { + if !strings.HasPrefix(metric.Name, metricName) { + continue + } + metric.Entry = entry + metric.Warning = metric.Warning.filterForSpecializedKeyword(keyword, entry) + metric.Critical = metric.Critical.filterForSpecializedKeyword(keyword, entry) + } +} + +// transforms keywords of ok/warn/crit thresholds, using a source keyword and target keyword +// attribute.name of the CheckData.attributes is fed into the formats as the first argument +// formatArgs are fed as following arguments +func (cd *CheckData) transformKeywordsUsingAttributes(keywordSourceFormat, keywordTargetFormat string, attributeNames []string, formatArgs ...any) { + for _, attributeName := range attributeNames { + args := make([]any, len(formatArgs)+1) + args[0] = attributeName + copy(args[1:], formatArgs) + + keywordSource := fmt.Sprintf(keywordSourceFormat, args...) + keywordTarget := fmt.Sprintf(keywordTargetFormat, args...) + + log.Tracef("Transforming threshold keywords, soruceKeywords: %v , targetKeyword: %s", keywordSource, keywordTarget) + + cd.warnThreshold = cd.TransformMultipleKeywords([]string{keywordSource}, keywordTarget, cd.warnThreshold) + cd.critThreshold = cd.TransformMultipleKeywords([]string{keywordSource}, keywordTarget, cd.critThreshold) + cd.okThreshold = cd.TransformMultipleKeywords([]string{keywordSource}, keywordTarget, cd.okThreshold) + } +} + +// for a given entry, looks through the ok/warn/crit thresholds, disables conditions using generallized keywords if specialized keywords is present +// attribute.name of the CheckData.attributes is fed into the formats as the first argument +// formatArgs are fed as following arguments +func (cd *CheckData) disableGenerallizedConditionsUsingAttributes(entry map[string]string, specializedKeywordFormat, generallizedKeywordFormat string, attributeNames []string, formatArgs ...any) { + for _, attributeName := range attributeNames { + args := make([]any, len(formatArgs)+1) + args[0] = attributeName + copy(args[1:], formatArgs) + + specializedKeyword := fmt.Sprintf(specializedKeywordFormat, args...) + generallizedKeyword := fmt.Sprintf(generallizedKeywordFormat, args...) + + cd.warnThreshold.disableGenerallizedConditionsForEntry(entry, []string{specializedKeyword}, []string{generallizedKeyword}) + cd.critThreshold.disableGenerallizedConditionsForEntry(entry, []string{specializedKeyword}, []string{generallizedKeyword}) + cd.okThreshold.disableGenerallizedConditionsForEntry(entry, []string{specializedKeyword}, []string{generallizedKeyword}) + } +} + // expand arg definitions separated by pipe symbol // ex.: -w|--warning func (cd *CheckData) expandArgDefinitions() { diff --git a/pkg/snclient/checkmetric.go b/pkg/snclient/checkmetric.go index 7046e2b7..e6ad7de9 100644 --- a/pkg/snclient/checkmetric.go +++ b/pkg/snclient/checkmetric.go @@ -23,7 +23,8 @@ type CheckMetric struct { CriticalStr *string // set critical from string Min *float64 Max *float64 - PerfConfig *PerfConfig // apply perf tweaks + PerfConfig *PerfConfig // apply perf tweaks + Entry map[string]string // entry that this metric is generated from } func (m *CheckMetric) String() string { @@ -156,6 +157,8 @@ func (m *CheckMetric) tweakedNum(rawNum any) (num, unit string) { return convert.Num2String(rawNum), m.Unit } +// Generate a string to be used in naemon like perfdata output about this threshold +// if a metric has a reference to its originating entry, the conditions will check if it is in their skip list func (m *CheckMetric) ThresholdString(conditions ConditionList) string { conv := func(rawNum any) string { num, _ := m.tweakedNum(rawNum) @@ -163,9 +166,30 @@ func (m *CheckMetric) ThresholdString(conditions ConditionList) string { return num } + conditionsToUseWhenBuildingPerfString := ConditionList{} + + for _, cond := range conditions { + if m.Entry == nil { + conditionsToUseWhenBuildingPerfString = append(conditionsToUseWhenBuildingPerfString, cond) + + continue + } + + if !cond.BlacklistWhitelistCheck(m.Entry) { + log.Tracef("metric knows which entry it was generated from, the condition does not allow this entry, skipping the condition for generating perf string, "+ + " name: %q , condition: %q , entry: %q", m.Name, cond.DetailedString(), m.Entry) + + continue + } + + conditionsToUseWhenBuildingPerfString = append(conditionsToUseWhenBuildingPerfString, cond) + } + + namesToUseWhenBuildingPerfString := []string{m.Name} + if m.ThresholdName != "" { - return ThresholdString([]string{m.Name, m.ThresholdName}, conditions, conv) + namesToUseWhenBuildingPerfString = append(namesToUseWhenBuildingPerfString, m.ThresholdName) } - return ThresholdString([]string{m.Name}, conditions, conv) + return ThresholdString(namesToUseWhenBuildingPerfString, conditionsToUseWhenBuildingPerfString, conv) } diff --git a/pkg/snclient/condition.go b/pkg/snclient/condition.go index ec23c8ad..26d14767 100644 --- a/pkg/snclient/condition.go +++ b/pkg/snclient/condition.go @@ -42,6 +42,15 @@ type Condition struct { // back reference to check attributes (used to expand by unit) attr *[]CheckAttribute + + // reference to data where this condition should NOT be evaluated + // can be any map[string]string, like check.details, or Entries in check.listData + blacklistData []map[string]string + + // reference to data where this condition should ONLY be evaluated + // can be any map[string]string, like check.details, or Entries in check.listData + // this works only if its populated + whitelistData []map[string]string } // Operator defines a filter operator. @@ -249,12 +258,51 @@ func (c *Condition) String() string { return fmt.Sprintf("%s %s %v%s", c.keyword, c.operator.String(), c.value, c.unit) } +// use this function to see more detail about a condition, including its original, unit and keyword +func (c *Condition) DetailedString() string { + if len(c.group) > 0 { + groups := make([]string, len(c.group)) + for i, g := range c.group { + groups[i] = g.DetailedString() + } + + return "(" + strings.Join(groups, " "+c.groupOperator.String()+" ") + ")" + } + + return fmt.Sprintf("Condition{kw: %s , op: %s , val: %v , un: %s , org: %s}", c.keyword, c.operator.String(), c.value, c.unit, c.original) +} + +// checks if a data of type map[string]string is allowed through the whitelist and blacklist of the condition +// returns true if its allowed beyond blacklist/whitelist +func (c *Condition) BlacklistWhitelistCheck(data map[string]string) (allowed bool) { + if utils.ContainsMap(c.blacklistData, data) { + log.Tracef("Condition does not allow this data, it is in its black list, condition: %q , data: %q", c.DetailedString(), data) + + return false + } + + if len(c.whitelistData) > 0 { + if !utils.ContainsMap(c.whitelistData, data) { + log.Tracef("Condition does not allow this data, it is not in conditions populated white list, condition: %q , data: %q", c.DetailedString(), data) + + return false + } + } + + return true +} + // Match checks if given map matches current condition // returns either the result or not ok if the result cannot be determined because of none-existing values func (c *Condition) Match(data map[string]string) (res, ok bool) { if c.isNone { return false, true } + + if !c.BlacklistWhitelistCheck(data) { + return false, false + } + if len(c.group) > 0 { finalOK := true for i := range c.group { @@ -510,6 +558,8 @@ func (c *Condition) Clone() *Condition { group: make(ConditionList, 0), attr: c.attr, original: c.original, + blacklistData: slices.Clone(c.blacklistData), + whitelistData: slices.Clone(c.whitelistData), } for i := range c.group { @@ -938,6 +988,24 @@ func (c *Condition) TransformMultipleKeywords(srcKeywords []string, targetKeywor return true } +// recursively gets list of all keywords used in the condition +func (c *Condition) GetListOfKeywords() (keywords []string, err error) { + addKeywordToList := func(c *Condition) (err error) { + keywords = append(keywords, c.keyword) + + return nil + } + + err = c.RunFuncRecursively(addKeywordToList) + if err != nil { + return nil, fmt.Errorf("error gathering keywords: %s", err.Error()) + } + + keywords = utils.Deduplicate(keywords) + + return keywords, nil +} + // pass an argument as a function. // the function should have a pointer receiver type, no arguments and return an error. // the function will be applied to the current instance. @@ -1039,19 +1107,20 @@ func conditionFixTokenOperator(token []string) []string { } // ThresholdString returns string used in warn/crit threshold performance data. -func ThresholdString(name []string, conditions ConditionList, numberFormat func(any) string) string { +// The name should be contained within the condition +func ThresholdString(names []string, conditions ConditionList, numberFormat func(any) string) string { // fetch warning conditions for name of metric filtered := make(ConditionList, 0) var group GroupOperator for num := range conditions { cond := conditions[num] - if slices.Contains(name, cond.keyword) { + if slices.Contains(names, cond.keyword) { filtered = append(filtered, cond) } if cond.groupOperator == GroupOr { group = cond.groupOperator for i := range cond.group { - if slices.Contains(name, cond.group[i].keyword) { + if slices.Contains(names, cond.group[i].keyword) { filtered = append(filtered, cond.group[i]) } } @@ -1059,7 +1128,7 @@ func ThresholdString(name []string, conditions ConditionList, numberFormat func( if cond.groupOperator == GroupAnd { group = cond.groupOperator for i := range cond.group { - if slices.Contains(name, cond.group[i].keyword) { + if slices.Contains(names, cond.group[i].keyword) { filtered = append(filtered, cond.group[i]) } } @@ -1105,7 +1174,7 @@ func ThresholdString(name []string, conditions ConditionList, numberFormat func( return fmt.Sprintf("%s:%s", numberFormat(low), numberFormat(high)) } - // implicite And + // implicit And return fmt.Sprintf("@%s:%s", numberFormat(low), numberFormat(high)) } @@ -1170,3 +1239,105 @@ func replaceStrOp(input string) string { return strings.Join(output, "") } + +// A condition list can contain some conditions that use a specialized keyword, and generallized keywords. +// This function only does modifications if there are conditions using the specialized keyword +// For all others that do not use the specizalied keyword, check if they are using a generallized keyword. +// After these two rounds of filtering conditions, disable a entry from this condition. +func (cl *ConditionList) disableGenerallizedConditionsForEntry(entry map[string]string, specializedKeywords, generallizedKeywords []string) { + conditionsWithSpecializedKeyword := cl.filterConditionsUsingKeywords(specializedKeywords) + if len(conditionsWithSpecializedKeyword) > 0 { + conditionsWithoutSpecializedKeyword := utils.SubtractSlice(*cl, conditionsWithSpecializedKeyword) + cl2 := ConditionList(conditionsWithoutSpecializedKeyword) + conditionsWithoutSpecializedKeywordAndGenerallizedKeyword := cl2.filterConditionsUsingKeywords(generallizedKeywords) + for _, cond := range conditionsWithoutSpecializedKeywordAndGenerallizedKeyword { + cond.blacklistData = append(cond.blacklistData, entry) + log.Tracef("Adding an entry to conditions blacklist, specialized keywords: %q , generallized keywords: %q , condition: %q , entry: %q", + specializedKeywords, generallizedKeywords, cond.DetailedString(), entry) + } + } +} + +// this function does not create new conditions, only filters existing conditions of ConditionList +func (cl *ConditionList) filterConditionsUsingKeywords(keywords []string) (ret []*Condition) { + for _, condition := range *cl { + if len(condition.group) > 0 { + groupRet := condition.group.filterConditionsUsingKeywords(keywords) + ret = append(ret, groupRet...) + } + + if slices.Contains(keywords, condition.keyword) { + ret = append(ret, condition) + } + } + + return ret +} + +// The match does not have to return true, it can return false +// The important point is that it is conclusive. This means it is permitted to match against the entry +func (cl *ConditionList) ifKeywordIsPresentAndPermitsEntry(keyword string, entry map[string]string) (result bool) { + result = false + + for _, condition := range *cl { + keywords, _ := condition.GetListOfKeywords() + if !slices.Contains(keywords, keyword) { + continue + } + + cl := ConditionList{condition} + subconditionsWithKeyword := cl.filterConditionsUsingKeywords([]string{keyword}) + for _, subcondition := range subconditionsWithKeyword { + if match, ok := subcondition.Match(entry); match && ok { + result = true + + break + } + } + + if result { + break + } + } + + return result +} + +// If a specialized condition, i.e a condition using the keyword, exists in this condition list, filter to specialized condition +// If a specialized condition does not exist, every condition is generallized. Keep every generallized condition. +func (cl *ConditionList) filterForSpecializedKeyword(keyword string, entry map[string]string) (result ConditionList) { + // If the condition has this keyword, and permits an entry, the condition is specific to this entry + // It is specialized in terms of this keyword, it is not generallized + keywordIsPresentAndPermitsEntry := cl.ifKeywordIsPresentAndPermitsEntry(keyword, entry) + + for _, condition := range *cl { + keywords, _ := condition.GetListOfKeywords() + + // keyword is not present + if !slices.Contains(keywords, keyword) { + // add generic condition in terms of keyword + if !keywordIsPresentAndPermitsEntry { + result = append(result, condition.Clone()) + } + + continue + } + + // has keyword — include only if at least one drive sub-condition permits this entry + subconditionsUsingKeyword := cl.filterConditionsUsingKeywords([]string{keyword}) + for _, dc := range subconditionsUsingKeyword { + if match, ok := dc.Match(entry); match && ok { + result = append(result, condition.Clone()) + + break + } + } + } + + if len(result) > 0 { + log.Tracef("From this condition list: %v , filtering to this specialized keyword: %s and entry: %v , special keyword exists and permits entry: %t , result: %v ", + *cl, keyword, entry, keywordIsPresentAndPermitsEntry, result) + } + + return result +} diff --git a/pkg/snclient/condition_test.go b/pkg/snclient/condition_test.go index 6c67c7a0..2279ea85 100644 --- a/pkg/snclient/condition_test.go +++ b/pkg/snclient/condition_test.go @@ -329,3 +329,236 @@ func TestConditionStrOp(t *testing.T) { output = replaceStrOp(input) assert.Equal(t, input, output) } + +func TestConditionBlacklist(t *testing.T) { + cond, err := NewCondition("a > 5", nil) + require.NoErrorf(t, err, "ConditionParse should throw no error") + + data1 := map[string]string{ + "a": "10", + "b": "20", + "c": "30", + } + + data1copysamevalues := map[string]string{ + "a": "10", + "b": "20", + "c": "30", + } + + res, ok := cond.Match(data1) + + assert.True(t, res, "result of the check before adding to blacklist should be true") + assert.True(t, ok, "result of the check before adding to blacklist should be conclusive") + + // add it to entry blacklist + cond.blacklistData = append(cond.blacklistData, data1) + + res, ok = cond.Match(data1) + + assert.False(t, res, "result of the check after adding to blacklist should be false") + assert.False(t, ok, "result of the check after adding to blacklist should be inconclusive") + + res, ok = cond.Match(data1copysamevalues) + + assert.True(t, res, "result of the check using copy of data1 should be true, blacklist only has data1") + assert.True(t, ok, "result of the check using copy of data1 should be conclusive, blacklist only has data1") + + res, ok = cond.Match(data1) + assert.False(t, res, "result after adding to blacklist should be false") + assert.False(t, ok, "result after adding to blacklist should be inconclusive") + data1["d"] = "40" + res, ok = cond.Match(data1) + assert.False(t, res, "result after modifying data1 should still be false") + assert.False(t, ok, "result of the check after modifying data1 should still be inconclusive") +} + +func TestConditionBlacklistMultipleEntries(t *testing.T) { + cond, err := NewCondition("a > 5", nil) + require.NoErrorf(t, err, "ConditionParse should throw no error") + + data1 := map[string]string{"a": "10"} + data2 := map[string]string{"a": "8"} + data3 := map[string]string{"a": "6"} + + cond.blacklistData = append(cond.blacklistData, data1, data2) + + res, ok := cond.Match(data1) + assert.False(t, res, "data1 is blacklisted") + assert.False(t, ok, "data1 inconclusive") + + res, ok = cond.Match(data2) + assert.False(t, res, "data2 is blacklisted") + assert.False(t, ok, "data2 inconclusive") + + res, ok = cond.Match(data3) + assert.True(t, res, "data3 is not blacklisted and matches the condition") + assert.True(t, ok, "data3 conclusive") +} + +func TestConditionBlacklistEmptyAllowsAll(t *testing.T) { + cond, err := NewCondition("a > 5", nil) + require.NoErrorf(t, err, "ConditionParse should throw no error") + + data1 := map[string]string{"a": "10"} + data2 := map[string]string{"a": "3"} + + // empty blacklist — all data passes through to normal matching + res, ok := cond.Match(data1) + assert.True(t, res, "data1 matches, blacklist empty") + assert.True(t, ok, "data1 conclusive") + + res, ok = cond.Match(data2) + assert.False(t, res, "data2 does not match condition, blacklist empty") + assert.True(t, ok, "data2 conclusive, only blocked by condition itself") +} + +func TestConditionBlacklistClone(t *testing.T) { + cond, err := NewCondition("a > 5", nil) + require.NoErrorf(t, err, "ConditionParse should throw no error") + + data1 := map[string]string{"a": "10"} + + cond.blacklistData = append(cond.blacklistData, data1) + + cloned := cond.Clone() + + res, ok := cloned.Match(data1) + assert.False(t, res, "cloned condition has blacklist data from original") + assert.False(t, ok, "inconclusive") + + // different underlying map with same values — not in blacklist + data2 := map[string]string{"a": "10"} + res, ok = cloned.Match(data2) + assert.True(t, res, "data2 has same values but different map, not in cloned blacklist") + assert.True(t, ok, "conclusive") +} + +func TestConditionWhitelist(t *testing.T) { + cond, err := NewCondition("a > 5", nil) + require.NoErrorf(t, err, "ConditionParse should throw no error") + + data1 := map[string]string{ + "a": "10", + "b": "20", + "c": "30", + } + + data2 := map[string]string{ + "a": "10", + "b": "20", + "c": "30", + } + + data3 := map[string]string{ + "a": "3", + } + + // before whitelist: matching data passes, non-matching fails + res, ok := cond.Match(data1) + assert.True(t, res, "data1 matches before whitelist is populated") + assert.True(t, ok, "data1 conclusive before whitelist is populated") + + res, ok = cond.Match(data3) + assert.False(t, res, "data3 does not match before whitelist is populated") + assert.True(t, ok, "data3 conclusive before whitelist is populated") + + // add data1 to whitelist — only data1 should be allowed through + cond.whitelistData = append(cond.whitelistData, data1) + + res, ok = cond.Match(data1) + assert.True(t, res, "data1 matches when it is in the whitelist") + assert.True(t, ok, "data1 conclusive when in the whitelist") + + // data2 has same values but different underlying map — whitelist uses map identity + res, ok = cond.Match(data2) + assert.False(t, res, "data2 does not match even with same values, not in whitelist") + assert.False(t, ok, "data2 inconclusive, not in whitelist") + + // data3: not in whitelist (even though it wouldn't match the condition anyway) + res, ok = cond.Match(data3) + assert.False(t, res, "data3 does not match, not in whitelist") + assert.False(t, ok, "data3 inconclusive, not in whitelist") + + // modifying whitelisted entry does not break identity + data1["d"] = "40" + res, ok = cond.Match(data1) + assert.True(t, res, "data1 still matches after modification, same underlying map") + assert.True(t, ok, "data1 still conclusive after modification") +} + +func TestConditionWhitelistOverridesBlacklist(t *testing.T) { + cond, err := NewCondition("a > 5", nil) + require.NoErrorf(t, err, "ConditionParse should throw no error") + + data1 := map[string]string{"a": "10"} + + // both blacklisted and whitelisted — blacklist takes precedence + cond.blacklistData = append(cond.blacklistData, data1) + cond.whitelistData = append(cond.whitelistData, data1) + + res, ok := cond.Match(data1) + assert.False(t, res, "blacklist takes precedence over whitelist") + assert.False(t, ok, "inconclusive due to blacklist") +} + +func TestConditionWhitelistMultipleEntries(t *testing.T) { + cond, err := NewCondition("a > 5", nil) + require.NoErrorf(t, err, "ConditionParse should throw no error") + + data1 := map[string]string{"a": "10"} + data2 := map[string]string{"a": "8"} + data3 := map[string]string{"a": "3"} + + cond.whitelistData = append(cond.whitelistData, data1, data2) + + res, ok := cond.Match(data1) + assert.True(t, res, "data1 is in whitelist and matches the condition") + assert.True(t, ok, "data1 conclusive") + + res, ok = cond.Match(data2) + assert.True(t, res, "data2 is in whitelist and matches the condition") + assert.True(t, ok, "data2 conclusive") + + res, ok = cond.Match(data3) + assert.False(t, res, "data3 is not in whitelist") + assert.False(t, ok, "data3 inconclusive, blocked by whitelist") +} + +func TestConditionWhitelistEmptyAllowsAll(t *testing.T) { + // empty whitelist means no whitelist restriction (all non-blacklisted data passes) + cond, err := NewCondition("a > 5", nil) + require.NoErrorf(t, err, "ConditionParse should throw no error") + + data1 := map[string]string{"a": "10"} + data2 := map[string]string{"a": "3"} + + res, ok := cond.Match(data1) + assert.True(t, res, "data1 matches, whitelist empty") + assert.True(t, ok, "data1 conclusive") + + res, ok = cond.Match(data2) + assert.False(t, res, "data2 does not match condition, whitelist empty") + assert.True(t, ok, "data2 conclusive, only blocked by condition itself") +} + +func TestConditionWhitelistClone(t *testing.T) { + cond, err := NewCondition("a > 5", nil) + require.NoErrorf(t, err, "ConditionParse should throw no error") + + data1 := map[string]string{"a": "10"} + + cond.whitelistData = append(cond.whitelistData, data1) + + cloned := cond.Clone() + + res, ok := cloned.Match(data1) + assert.True(t, res, "cloned condition has whitelist data from original") + assert.True(t, ok, "conclusive") + + // different underlying map with same values + data2 := map[string]string{"a": "10"} + res, ok = cloned.Match(data2) + assert.False(t, res, "data2 not in cloned whitelist") + assert.False(t, ok, "inconclusive") +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 78713a8e..049b0574 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -17,6 +17,7 @@ import ( "net/http" "os" "path/filepath" + "reflect" "regexp" "runtime" "slices" @@ -697,3 +698,58 @@ func ReplaceNumbersWithZeroPadded(s string, padding int) string { return fmt.Sprintf(format, num) }) } + +// generic function to subtract all elements of op2 from op1 +// this does not modify op1 or op2 +func SubtractSlice[T comparable](op1, op2 []T) (ret []T) { + toRemove := make(map[T]struct{}, len(op2)) + + for _, elem := range op2 { + toRemove[elem] = struct{}{} + } + + op1Copy := make([]T, len(op1)) + copy(op1Copy, op1) + + return slices.DeleteFunc(op1Copy, func(elem T) bool { + _, exists := toRemove[elem] + + return exists + }) +} + +// MapsEqual returns true if two map[string]string values refer to the same underlying hash table. +// In Go, maps are reference types — copying a map copies the header (a pointer to the internal hmap struct), +// so all copies of the same map share the same underlying data and the same hmap address. +// Go does not move heap objects (no compacting GC), so the hmap pointer is stable for the map's lifetime. +// Maps are not directly == comparable, so reflect is used to extract and compare the internal pointer. +// This is useful for checking whether two maps represent the same logical entry (e.g. skip-list lookups). +func MapsEqual(a, b map[string]string) bool { + return reflect.ValueOf(a).Pointer() == reflect.ValueOf(b).Pointer() +} + +// ContainsMap returns true if a map in the slice shares the same underlying hash table as target. +// See MapsEqual for details on the comparison mechanism. +func ContainsMap(slice []map[string]string, target map[string]string) bool { + for _, m := range slice { + if MapsEqual(m, target) { + return true + } + } + + return false +} + +// Deduplicate removes duplicate values from a slice, preserving order. +func Deduplicate[T comparable](slice []T) []T { + seen := make(map[T]struct{}, len(slice)) + result := make([]T, 0, len(slice)) + for _, v := range slice { + if _, exists := seen[v]; !exists { + seen[v] = struct{}{} + result = append(result, v) + } + } + + return result +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index d26a0325..4bfaeb45 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -193,3 +193,129 @@ func TestRankedSort(t *testing.T) { assert.Equalf(t, expected, sorted, "sorted by rank") } + +func TestSubtractSlice(t *testing.T) { + numbersSlice1 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + numbersSlice2 := []int{1, 3, 5, 7, 9} + + numberSliceRes := SubtractSlice(numbersSlice1, numbersSlice2) + numberSlicesExpected := []int{2, 4, 6, 8, 10} + + assert.ElementsMatch(t, numberSliceRes, numberSlicesExpected, "removing odd numbers from [1..10] should give even numbers.") +} + +func TestMapsEqual(t *testing.T) { + dataMaster := map[string]string{ + "x": "x", + "y": "y", + "z": "z", + } + + copyUsingAssignment := dataMaster + assert.True(t, MapsEqual(dataMaster, copyUsingAssignment), "map comparisons should be equal, assigned to new value") + + savedPtr := &dataMaster + assert.True(t, MapsEqual(dataMaster, *savedPtr), "map comparisons should be equal, reference assigned to new value") + + type st struct { + data map[string]string + dataPtr *map[string]string + } + savedInStruct := st{ + data: dataMaster, + dataPtr: &dataMaster, + } + assert.True(t, MapsEqual(dataMaster, savedInStruct.data), "map comparisons should be equal, assigned to new value in struct") + assert.True(t, MapsEqual(dataMaster, *savedInStruct.dataPtr), "map comparisons should be equal, referece assigned to new value in struct") + + savedInList := []map[string]string{dataMaster} + assert.True(t, MapsEqual(dataMaster, savedInList[0]), "map comparisons should be equal, saved to a list") + + savedInPtrList := []*map[string]string{&dataMaster} + assert.True(t, MapsEqual(dataMaster, *savedInPtrList[0]), "map comparisons should be equal, referece saved to a list") + + // modifying master, the references should stay the same + dataMaster["a"] = "a" + dataMaster["b"] = "b" + dataMaster["c"] = "c" + dataMaster["d"] = "d" + dataMaster["e"] = "e" + dataMaster["f"] = "f" + + // check again after master is modified + + assert.True(t, MapsEqual(dataMaster, copyUsingAssignment), "map comparisons should be equal, assigned to new value") + assert.Equal(t, "a", copyUsingAssignment["a"], "map variable should point to same table, assigned to new value") + + assert.True(t, MapsEqual(dataMaster, *savedPtr), "map comparisons should be equal, reference assigned to new value") + assert.Equal(t, "b", (*savedPtr)["b"], "map variable should point to same table, reference assigned to new value") + + assert.True(t, MapsEqual(dataMaster, savedInStruct.data), "map comparisons should be equal, assigned to new value in struct") + assert.Equal(t, "c", savedInStruct.data["c"], "map variable should point to same table, assigned to new value in struct") + + assert.True(t, MapsEqual(dataMaster, *savedInStruct.dataPtr), "map comparisons should be equal, referece assigned to new value in struct") + assert.Equal(t, "d", savedInStruct.data["d"], "map variable should point to same table, referece assigned to new value in struct") + + assert.True(t, MapsEqual(dataMaster, savedInList[0]), "map comparisons should be equal, saved to a list") + assert.Equal(t, "e", savedInStruct.data["e"], "map variable should point to same table, saved to a list") + + assert.True(t, MapsEqual(dataMaster, *savedInPtrList[0]), "map comparisons should be equal, referece saved to a list") + assert.Equal(t, "f", savedInStruct.data["f"], "map variable should point to same table, referece saved to a list") + + dataMaster2 := map[string]string{} + assert.False(t, MapsEqual(dataMaster, dataMaster2), "map comparisons should be false, dataMaster2 is a new map") + + // fill up the newData to be same as dataMaster + dataMaster2["a"] = "a" + dataMaster2["b"] = "b" + dataMaster2["c"] = "c" + dataMaster2["d"] = "d" + dataMaster2["e"] = "e" + dataMaster2["f"] = "f" + dataMaster2["x"] = "x" + dataMaster2["y"] = "y" + dataMaster2["z"] = "z" + + assert.Equal(t, dataMaster, dataMaster2, "both dataMaster and dataMaster2 has the same key-values, in the same order") + assert.False(t, MapsEqual(dataMaster, dataMaster2), "map comparisons should be false, dataMaster2 has the same key-values but is a new table") +} + +func TestContainsMap(t *testing.T) { + map1 := map[string]string{ + "asd": "asd", + "xyz": "xyz", + } + + map1assigned := map1 + + map2 := map[string]string{ + "snclient": "snclient", + } + + map2referenceassined := &map2 + + map3 := map[string]string{ + "foo": "foo", + "bar": "bar", + } + + map3inlist := []map[string]string{map3} + + list := []map[string]string{map1, map2, map3} + + assert.True(t, ContainsMap(list, map1), "should contain first map") + assert.True(t, ContainsMap(list, map1assigned), "should contain first map assigned") + + assert.True(t, ContainsMap(list, map2), "should contain second map") + assert.True(t, ContainsMap(list, *map2referenceassined), "should contain second map reference assigned") + + assert.True(t, ContainsMap(list, map3), "should contain third map") + assert.True(t, ContainsMap(list, map3inlist[0]), "should contain third map saved in list") + + map4 := map[string]string{ + "foo": " foo", + "bar": "bar", + } + + assert.False(t, ContainsMap(list, map4), "should not fourth map, it has same keys-values as map3 but is separately initialized") +}