From e7808118256fc24536827a896a3572a8f493c592 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Wed, 5 Nov 2025 18:33:34 +0100 Subject: [PATCH 1/8] - feature: add transformation of conditional operands at the end of parseArgs. The code checks the check task type before doing any conversions. Currently it only converts timestamps --- pkg/snclient/checkdata.go | 49 +++++++++++++++++++++++++++++++++++ pkg/snclient/condition.go | 54 ++++++++++++++++++++++++++++----------- 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/pkg/snclient/checkdata.go b/pkg/snclient/checkdata.go index 9433d618..03983e01 100644 --- a/pkg/snclient/checkdata.go +++ b/pkg/snclient/checkdata.go @@ -751,6 +751,8 @@ func (cd *CheckData) parseArgs(args []string) (argList []Argument, err error) { cd.applyConditionAlias() + cd.TransofrmFilterKeywords() + return argList, nil } @@ -904,6 +906,53 @@ func (cd *CheckData) removeQuotes(str string) string { return str } +func (cd *CheckData) TransofrmFilterKeywords() error { + + // Maybe this should be a flag in Check struct + checks_with_time_keywords := []string{ + "check_files", + // possibly more? + } + + now := time.Now().In(cd.timezone) + // These timestamps are generated for the first nanosecond of a day, e.g 2025-11-05T00:00:00Z + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, cd.timezone) + one_year_ago := today.AddDate(-1, 0, 0) + one_month_ago := today.AddDate(0, -1, 0) + one_week_ago := today.AddDate(0, 0, -1) + yesterday := today.AddDate(0, 0, -1) + tomorrow := today.AddDate(0, 0, 1) + + time_associations := map[string]time.Time{ + "today": today, + "one_year_ago": one_year_ago, + "a_year_ago": one_year_ago, + "one_month_ago": one_month_ago, + "a_month_ago": one_month_ago, + "a_week_ago": one_week_ago, + "one_week_ago": one_week_ago, + "yesterday": yesterday, + "tomorrow": tomorrow, + } + + for _, condition := range cd.filter { + operand, ok1 := condition.value.(string) + if !ok1 { + continue + } + if associated_time, ok := time_associations[operand]; ok { + if slices.Contains(checks_with_time_keywords, cd.name) { + condition.keywordTransformed = true + condition.value = strconv.FormatInt(associated_time.Unix(), 10) + } else { + log.Warnf("There seems to be a time based operand in the condition with the original string: '%s' . But the task %s is not marked for transforming time based operands", condition.original, cd.name) + } + } + } + + return nil +} + // setFallbacks sets default filter/warn/crit thresholds unless already set. func (cd *CheckData) setFallbacks(applyDefaultFilter bool, defaultWarning, defaultCritical string) error { if applyDefaultFilter && cd.defaultFilter != "" { diff --git a/pkg/snclient/condition.go b/pkg/snclient/condition.go index 4c406242..bc9eb158 100644 --- a/pkg/snclient/condition.go +++ b/pkg/snclient/condition.go @@ -24,7 +24,11 @@ var ( type Condition struct { noCopy noCopy - keyword string + // keyword is the original operand given in the condition + keyword string + // some keywords can be transformed to another values during runtime. For example "today" can be transformed into todays UNIX timestamp. This directly influences the value attribute, so this is just a flag for debugging + keywordTransformed bool + operator Operator value interface{} unit string @@ -314,58 +318,78 @@ func (c *Condition) matchSingle(data map[string]string) (res, ok bool) { if !ok { return false, false } + // value of the condition, i.e condStr := fmt.Sprintf("%v", c.value) varNum, err1 := strconv.ParseFloat(varStr, 64) condNum, err2 := strconv.ParseFloat(condStr, 64) + if err1 != nil { + log.Debugf("Parsing varStr: %s into a float was unsuccessful, number comparison based conditionals might give wrong results", varStr) + } + if err2 != nil { + log.Debugf("Parsing condNum: %s into a float was unsuccessful, number comparison based conditionals might give wrong results", condStr) + } + // Use a different kind of parsing if one side of the conditional is given as "version". if c.keyword == "version" { varNum, err1 = convert.VersionF64E(varStr) condNum, err2 = convert.VersionF64E(condStr) } switch c.operator { case Equal: + // parsing to a numbers worked, both the discovered data and the operand of the comparison were presumably numbers. + // safe to compare them numerically if err1 == nil && err2 == nil { return varNum == condNum, true } - // fallback to string compare + // could not parse both operands to numbers, but this comparison can be done on strings return condStr == varStr, true case Unequal: + // parsing to a numbers worked, both the discovered data and the operand of the comparison were presumably numbers. + // safe to compare them numerically if err1 == nil && err2 == nil { return varNum != condNum, true } - // fallback to string compare + // could not parse both operands to numbers, but this comparison can be done on strings return condStr != varStr, true - case Contains: - return strings.Contains(strings.ToLower(varStr), strings.ToLower(condStr)), true - case ContainsNot: - return !strings.Contains(strings.ToLower(varStr), strings.ToLower(condStr)), true - case ContainsCase: - return strings.Contains(varStr, condStr), true - case ContainsNotCase: - return !strings.Contains(varStr, condStr), true case GreaterEqual: + // parsing to a numbers worked, both the discovered data and the operand of the comparison were presumably numbers. + // safe to compare them numerically if err1 == nil && err2 == nil { return varNum >= condNum, true } - + // could not parse both operands to numbers, and this comparison type is strictly for numbers return false, true case Greater: + // parsing to a numbers worked, both the discovered data and the operand of the comparison were presumably numbers. + // safe to compare them numerically if err1 == nil && err2 == nil { return varNum > condNum, true } - + // could not parse both operands to numbers, and this comparison type is strictly for numbers return false, true case LowerEqual: + // parsing to a numbers worked, both the discovered data and the operand of the comparison were presumably numbers. + // safe to compare them numerically if err1 == nil && err2 == nil { return varNum <= condNum, true } - + // could not parse both operands to numbers, and this comparison type is strictly for numbers return false, true case Lower: + // parsing to a numbers worked, both the discovered data and the operand of the comparison were presumably numbers. + // safe to compare them numerically if err1 == nil && err2 == nil { return varNum < condNum, true } - + // could not parse both operands to numbers, and this comparison type is strictly for numbers return false, true + case Contains: + return strings.Contains(strings.ToLower(varStr), strings.ToLower(condStr)), true + case ContainsNot: + return !strings.Contains(strings.ToLower(varStr), strings.ToLower(condStr)), true + case ContainsCase: + return strings.Contains(varStr, condStr), true + case ContainsNotCase: + return !strings.Contains(varStr, condStr), true case RegexMatch: regex, err := regexp.Compile(condStr) if err != nil { From 9b5c0c8338118b8ecdad18258f365bab961b8866 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Thu, 6 Nov 2025 18:03:20 +0100 Subject: [PATCH 2/8] - add a testing script for the check_files operands are given in natural language --- pkg/snclient/check_files_test.go | 75 +++++++++++++++++++ .../t/scripts/check_files_generate_files.sh | 39 ++++++++++ 2 files changed, 114 insertions(+) create mode 100755 pkg/snclient/t/scripts/check_files_generate_files.sh diff --git a/pkg/snclient/check_files_test.go b/pkg/snclient/check_files_test.go index 5dfab750..d62fa362 100644 --- a/pkg/snclient/check_files_test.go +++ b/pkg/snclient/check_files_test.go @@ -128,3 +128,78 @@ func TestCheckFilesNoPermission(t *testing.T) { StopTestAgent(t, snc) } + +func check_files_configfile(t *testing.T, scriptsDir, scriptsType string) string { + t.Helper() + + config := fmt.Sprintf(` +[/modules] +CheckExternalScripts = enabled + +[/paths] +scripts = %s +shared-path = %%(scripts) + +[/settings/external scripts/wrappings] +sh = %%SCRIPT%% %%ARGS%% +exe = %%SCRIPT%% %%ARGS%% + +[/settings/external scripts] +timeout = 1111111 +allow arguments = true + +[/settings/external scripts/scripts] +check_files_generate_files = ./check_files_generate_files.EXTENSION "$ARG1$" +`, scriptsDir) + + config = strings.ReplaceAll(config, "EXTENSION", scriptsType) + + return config +} + +func TestTimeKeywordFilters(t *testing.T) { + + // prepare a tempdir + tempDir := t.TempDir() + + fmt.Printf("tempDir= %s \n", tempDir) + + testDir, _ := os.Getwd() + scriptsDir := filepath.Join(testDir, "t", "scripts") + + config := check_files_configfile(t, scriptsDir, "sh") + snc := StartTestAgent(t, config) + + // There is a bash script on this path: pkg/snclient/t/scripts/check_files_generate_files.sh + // It generates files on a temporary path, and changes their modification date + // This script is added to the snclient config first and registered as a check command, then ran by the snclient executable itself + res := snc.RunCheck("check_files_generate_files", []string{tempDir}) + + // one_year_from_now_on + // one_month_from_now_on + // one_week_from_now_on + // two_days_from_now_on + // tomorrow + // today + // yesterday + // two_days_ago + // one_week_ago + // one_month_ago + // one_year_ago + assert.Equalf(t, CheckExitOK, res.State, "Generating test files successfull") + assert.Equalf(t, string(res.BuildPluginOutput()), "ok - Generated 11 files for testing", "output matches") + + // Note on 2025-11-06 : Multiple filter=""s are combined with a logical OR. + // res = snc.RunCheck("check_files", []string{fmt.Sprintf("path=%s", tempDir), "filter=\"written>=today\"", "filter=\"writtentoday\""}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + assert.Containsf(t, string(res.BuildPluginOutput()), "OK - All 5 files are ok", "output matches") + + StopTestAgent(t, snc) +} diff --git a/pkg/snclient/t/scripts/check_files_generate_files.sh b/pkg/snclient/t/scripts/check_files_generate_files.sh new file mode 100755 index 00000000..407f9291 --- /dev/null +++ b/pkg/snclient/t/scripts/check_files_generate_files.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# This script is intended to generate files to be tested for check_files +# These files need to be generated dynamically, so they cant be simply saved in the repository. + +set -o errexit +set -o nounset +set -o pipefail + +TESTING_DIR="$1" + +mkdir -p ${TESTING_DIR} + +TODAY=$(date -d 'today 00:00:00' +%F) + +echo "${TODAY} ${TESTING_DIR}" > /tmp/asdasdasd + +( + cd ${TESTING_DIR} + + touch --time modify --date $(date -d "${TODAY} - 1 years" +%FT%T) one_year_ago + touch --time modify --date $(date -d "${TODAY} - 1 months " +%FT%T) one_month_ago + touch --time modify --date $(date -d "${TODAY} - 1 week" +%FT%T) one_week_ago + touch --time modify --date $(date -d "${TODAY} - 2 days" +%FT%T) two_days_ago + touch --time modify --date $(date -d "${TODAY} - 1 days" +%FT%T) yesterday + touch --time modify --date $(date -d "${TODAY}" +%FT%T) today + touch --time modify --date $(date -d "${TODAY} + 1 days" +%FT%T) tomorrow + touch --time modify --date $(date -d "${TODAY} + 2 days" +%FT%T) two_days_from_now_on + touch --time modify --date $(date -d "${TODAY} + 1 weeks" +%FT%T) one_week_from_now_on + touch --time modify --date $(date -d "${TODAY} + 1 month" +%FT%T) one_month_from_now_on + touch --time modify --date $(date -d "${TODAY} + 1 years" +%FT%T) one_year_from_now_on + + FILE_COUNT=$(find ${TESTING_DIR} -type f -printf "." | wc -c) + + echo "ok - Generated ${FILE_COUNT} files for testing" +) + +exit 0 + From 09df8554152ee57233e6f3648b254ca04011e3c8 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Thu, 6 Nov 2025 18:07:55 +0100 Subject: [PATCH 3/8] - move the time keyword parser in the utils, remove it from checkdata.go - add the ability to ascertain the beginning of this week this_day i.e today, this_week, this_month, this_year - add the ability to move forward/backward in time using the suffixes 'ago' and 'from_now_on' - add the ability to parse arbitrary numbers for the temporal move, as long as its given in digits - now move in time with the library function, is more robust against leap days etc. - integrate the parsing of such time keywords into the unit system. now these types of time phrases can be used anywhere in the code --- pkg/snclient/checkdata.go | 49 --------------------- pkg/snclient/condition.go | 18 +++++++- pkg/utils/utils.go | 93 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 50 deletions(-) diff --git a/pkg/snclient/checkdata.go b/pkg/snclient/checkdata.go index 03983e01..9433d618 100644 --- a/pkg/snclient/checkdata.go +++ b/pkg/snclient/checkdata.go @@ -751,8 +751,6 @@ func (cd *CheckData) parseArgs(args []string) (argList []Argument, err error) { cd.applyConditionAlias() - cd.TransofrmFilterKeywords() - return argList, nil } @@ -906,53 +904,6 @@ func (cd *CheckData) removeQuotes(str string) string { return str } -func (cd *CheckData) TransofrmFilterKeywords() error { - - // Maybe this should be a flag in Check struct - checks_with_time_keywords := []string{ - "check_files", - // possibly more? - } - - now := time.Now().In(cd.timezone) - // These timestamps are generated for the first nanosecond of a day, e.g 2025-11-05T00:00:00Z - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, cd.timezone) - one_year_ago := today.AddDate(-1, 0, 0) - one_month_ago := today.AddDate(0, -1, 0) - one_week_ago := today.AddDate(0, 0, -1) - yesterday := today.AddDate(0, 0, -1) - tomorrow := today.AddDate(0, 0, 1) - - time_associations := map[string]time.Time{ - "today": today, - "one_year_ago": one_year_ago, - "a_year_ago": one_year_ago, - "one_month_ago": one_month_ago, - "a_month_ago": one_month_ago, - "a_week_ago": one_week_ago, - "one_week_ago": one_week_ago, - "yesterday": yesterday, - "tomorrow": tomorrow, - } - - for _, condition := range cd.filter { - operand, ok1 := condition.value.(string) - if !ok1 { - continue - } - if associated_time, ok := time_associations[operand]; ok { - if slices.Contains(checks_with_time_keywords, cd.name) { - condition.keywordTransformed = true - condition.value = strconv.FormatInt(associated_time.Unix(), 10) - } else { - log.Warnf("There seems to be a time based operand in the condition with the original string: '%s' . But the task %s is not marked for transforming time based operands", condition.original, cd.name) - } - } - } - - return nil -} - // setFallbacks sets default filter/warn/crit thresholds unless already set. func (cd *CheckData) setFallbacks(applyDefaultFilter bool, defaultWarning, defaultCritical string) error { if applyDefaultFilter && cd.defaultFilter != "" { diff --git a/pkg/snclient/condition.go b/pkg/snclient/condition.go index bc9eb158..420c2ac1 100644 --- a/pkg/snclient/condition.go +++ b/pkg/snclient/condition.go @@ -820,7 +820,23 @@ func (c *Condition) expandUnitByType(str string) error { c.unit = "B" return nil - case UDate, UTimestamp: + case UDate: + value, err := utils.ExpandDuration(str) + if err != nil { + parsed_time, err := utils.ParseTimeKeyword(str) + if err != nil { + return fmt.Errorf("invalid duration value: %s", err.Error()) + } else { + c.value = float64(parsed_time.Unix()) + c.unit = "" + } + + } else { + c.value = strconv.FormatFloat(float64(time.Now().Unix())+value, 'f', 0, 64) + c.unit = "" + } + return nil + case UTimestamp: value, err := utils.ExpandDuration(str) if err != nil { return fmt.Errorf("invalid duration value: %s", err.Error()) diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 0629d837..72f82c16 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -46,6 +46,99 @@ var TimeFactors = []struct { {"y", 86400 * 365}, } +// The user can give a filter like 2_weeks_ago, 3_months_ago , 5_years_from_now_on etc. +func ParseTimeKeyword(time_keyword string) (time.Time, error) { + var time_keyword_being_processed string + var natural_language_conversion_ok bool + // convert words into the forms where the temporal direction, base and the numerical value can be parsed + natural_language_keywords := map[string]string{ + "today": "0_days_ago", + "tomorrow": "1_days_from_now_on", + "yesterday": "1_days_ago", + "this_week": "0_weeks_ago", + "1_week_ago": "1_weeks_ago", + "1_week_from_now_on": "1_weeks_from_now_on", + "this_month": "0_months_ago", + "1_month_ago": "1_months_ago", + "1_month_from_now_on": "1_months_from_now_on", + "this_year": "0_years_ago", + "1_year_ago": "1_years_ago", + "1_year_from_now_on": "1_years_from_now_on", + } + // this conversion may fail, but that is ok + if time_keyword_being_processed, natural_language_conversion_ok = natural_language_keywords[time_keyword]; !natural_language_conversion_ok { + // reset if assignment failed + time_keyword_being_processed = time_keyword + } + + var temporal_backward, temporal_forward bool + time_keyword_being_processed, temporal_backward = strings.CutSuffix(time_keyword_being_processed, "_ago") + time_keyword_being_processed, temporal_forward = strings.CutSuffix(time_keyword_being_processed, "_from_now_on") + if temporal_backward == temporal_forward { + return time.Now(), fmt.Errorf("keyword: '%s' has to have survive exactly one suffix cuts of 'ago' and 'from_now_on'. Cannot ascertain temporal direction", time_keyword) + } + + // TODO: handle the timezone question? + timezone := time.UTC + + now := time.Now().In(time.UTC) + today_midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, timezone) + + var suffix_days_cut, suffix_weeks_cut, suffix_months_cut, suffix_years_cut bool + durationCutCount := 0 // Cant just convert the bool variables to int, golang does not allow it + // Day based keywords use today as a basis, Week based keywords use this week as a basis. + // We have to determine the beginning of that point, then move forward/backward + var t0 time.Time + if time_keyword_being_processed, suffix_days_cut = strings.CutSuffix(time_keyword_being_processed, "_days"); suffix_days_cut { + durationCutCount++ + t0 = today_midnight + } + if time_keyword_being_processed, suffix_weeks_cut = strings.CutSuffix(time_keyword_being_processed, "_weeks"); suffix_weeks_cut { + durationCutCount++ + // this should take values between 0 for Monday, and 6 for Sunday. + // But the golang started Sunday at 0 and Monday at 1 + weekday := today_midnight.Weekday() + days_to_subtract := int(weekday) - int(time.Monday) + if days_to_subtract < 0 { + days_to_subtract += 7 // If today is Sunday (0), go back 6 days to Monday + } + t0 = today_midnight.AddDate(0, 0, -days_to_subtract) + } + if time_keyword_being_processed, suffix_months_cut = strings.CutSuffix(time_keyword_being_processed, "_months"); suffix_months_cut { + durationCutCount++ + t0 = time.Date(today_midnight.Year(), today_midnight.Month(), 1, 0, 0, 0, 0, timezone) + } + if time_keyword_being_processed, suffix_years_cut = strings.CutSuffix(time_keyword_being_processed, "_years"); suffix_years_cut { + durationCutCount++ + t0 = time.Date(today_midnight.Year(), time.January, 1, 0, 0, 0, 0, timezone) + } + if durationCutCount != 1 { + return time.Now(), fmt.Errorf("keyword: '%s' has to have survive exactly one suffix cuts of 'days', 'weeks', 'months', 'years' ", time_keyword) + } + + move_count, move_count_ok := strconv.ParseInt(time_keyword_being_processed, 10, 32) + if move_count_ok != nil { + return time.Now(), fmt.Errorf("keyword: '%s' could not parse of the time basis to move front/backwards in time", time_keyword) + } + if temporal_backward { + move_count *= -1 + } + + var result time.Time + if suffix_days_cut { + result = t0.AddDate(0, 0, int(move_count)) + } else if suffix_weeks_cut { + result = t0.AddDate(0, 0, 7*int(move_count)) + } else if suffix_months_cut { + result = t0.AddDate(0, int(move_count), 0) + } else if suffix_years_cut { + result = t0.AddDate(int(move_count), 0, 0) + } + + return result, nil + +} + // ExpandDuration expand duration string into seconds func ExpandDuration(val string) (res float64, err error) { var num float64 From 8e9c480eb12967ece2062f2d28417acde7e9457d Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Fri, 7 Nov 2025 11:58:57 +0100 Subject: [PATCH 4/8] - add more keywords for date parsing: this/next/last week/month/year combinations - integrate date keyword parsing better into the conditional operand parsing. If the regex for detecting an operand fails, it also tries to parse it as a date keyword. - more explanatory errors in conditional parsing. Fix error propagation, previously some cases were just returning nils - write a test function for parsing the date keyword --- pkg/snclient/condition.go | 51 ++++++++++++++++++++-------------- pkg/utils/utils.go | 16 ++++++++--- pkg/utils/utils_test.go | 58 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 25 deletions(-) diff --git a/pkg/snclient/condition.go b/pkg/snclient/condition.go index 420c2ac1..c91382de 100644 --- a/pkg/snclient/condition.go +++ b/pkg/snclient/condition.go @@ -795,17 +795,26 @@ func (c *Condition) getUnit(keyword string) Unit { func (c *Condition) expandUnitByType(str string) error { match := reConditionValueUnit.FindStringSubmatch(str) - if len(match) < 3 { - c.value = str - return nil + if len(match) >= 3 { + c.value = match[1] + c.unit = match[2] + } else { + c.value = str + // before returning null, check if it can be parsed as date + _, date_parsing_err := utils.ParseDateKeyword(str) + if date_parsing_err == nil { + // it can be parsed as a + c.value = str + } else { + // it cant be parsed as date as well + return fmt.Errorf("could not parse the condition operand: %s with regex, or as a date keyword with this error: %s", str, date_parsing_err.Error()) + } } - c.value = match[1] - c.unit = match[2] // bytes value support % thresholds as well but we cannot expand them yet if c.unit == "%" { - return nil + return fmt.Errorf("parsed the condition operand: %s with regex, but the unit was determined as '%'. Expanding them is not implemented yet", str) } // expand known units @@ -814,32 +823,32 @@ func (c *Condition) expandUnitByType(str string) error { case UByte: value, err := humanize.ParseBytes(str) if err != nil { - return fmt.Errorf("invalid bytes value: %s", err.Error()) + return fmt.Errorf("Type of this conditional operand: %s was determined to be an UByte. It was however not parsed as bytes, getting this error: %s", str, err.Error()) } c.value = strconv.FormatUint(value, 10) c.unit = "B" return nil case UDate: - value, err := utils.ExpandDuration(str) - if err != nil { - parsed_time, err := utils.ParseTimeKeyword(str) - if err != nil { - return fmt.Errorf("invalid duration value: %s", err.Error()) - } else { - c.value = float64(parsed_time.Unix()) - c.unit = "" - } - - } else { + value, duration_parse_error := utils.ExpandDuration(str) + if duration_parse_error == nil { + // The expandDuration parses the duration, not a specific date. If the user gives '10d', try to get the date from now on plus 10 days c.value = strconv.FormatFloat(float64(time.Now().Unix())+value, 'f', 0, 64) c.unit = "" + return nil } - return nil + parsed_time, date_parse_error := utils.ParseDateKeyword(str) + if date_parse_error == nil { + // Parse time keyword returns the specific time and not a difference. No need to add it to current date + c.value = float64(parsed_time.Unix()) + c.unit = "" + return nil + } + return fmt.Errorf("Type of this conditional operand: %s was determined to be an UDate. It was not parsed as a duration, getting the error: %s . It was also not parsed as a specific date keyword, getting the error: %s", str, duration_parse_error.Error(), date_parse_error.Error()) case UTimestamp: value, err := utils.ExpandDuration(str) if err != nil { - return fmt.Errorf("invalid duration value: %s", err.Error()) + return fmt.Errorf("Type of this conditional operand: %s was determined to be an UTimestamp. It was not parsed as a duration, getting this error: %s", str, err.Error()) } c.value = strconv.FormatFloat(float64(time.Now().Unix())+value, 'f', 0, 64) c.unit = "" @@ -848,7 +857,7 @@ func (c *Condition) expandUnitByType(str string) error { case UDuration: value, err := utils.ExpandDuration(str) if err != nil { - return fmt.Errorf("invalid duration value: %s", err.Error()) + return fmt.Errorf("Type of this conditional operand: %s was determined to be an UDuration. It was not parsed as a duration, getting this error: %s", str, err.Error()) } c.value = strconv.FormatFloat(value, 'f', 0, 64) c.unit = "s" diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 72f82c16..2aed67ab 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -33,6 +33,7 @@ import ( var reMountPassword = regexp.MustCompile(`//.*:.*@`) +// Different time measurements and their value measured in seconds var TimeFactors = []struct { suffix string factor float64 @@ -47,7 +48,8 @@ var TimeFactors = []struct { } // The user can give a filter like 2_weeks_ago, 3_months_ago , 5_years_from_now_on etc. -func ParseTimeKeyword(time_keyword string) (time.Time, error) { +// The return values from this function refer to a specific time, e.g beginning of the week. It does not give the duration difference with current time +func ParseDateKeyword(time_keyword string) (time.Time, error) { var time_keyword_being_processed string var natural_language_conversion_ok bool // convert words into the forms where the temporal direction, base and the numerical value can be parsed @@ -56,12 +58,18 @@ func ParseTimeKeyword(time_keyword string) (time.Time, error) { "tomorrow": "1_days_from_now_on", "yesterday": "1_days_ago", "this_week": "0_weeks_ago", - "1_week_ago": "1_weeks_ago", + "next_week": "1_weeks_from_now_on", + "last_week": "1_weeks_ago", "1_week_from_now_on": "1_weeks_from_now_on", + "1_week_ago": "1_weeks_ago", "this_month": "0_months_ago", - "1_month_ago": "1_months_ago", + "next_month": "1_months_from_now_on", + "last_month": "1_months_ago", "1_month_from_now_on": "1_months_from_now_on", + "1_month_ago": "1_months_ago", "this_year": "0_years_ago", + "next_year": "1_years_from_now_on", + "last_year": "1_years_ago", "1_year_ago": "1_years_ago", "1_year_from_now_on": "1_years_from_now_on", } @@ -139,7 +147,7 @@ func ParseTimeKeyword(time_keyword string) (time.Time, error) { } -// ExpandDuration expand duration string into seconds +// ExpandDuration expand duration string into seconds , e.g '2h' -> 7200 func ExpandDuration(val string) (res float64, err error) { var num float64 diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 92e95910..93dd3850 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -29,6 +29,64 @@ func TestUtilsExpandDuration(t *testing.T) { } } +func TestUtilsParseDateKeyword(t *testing.T) { + + tests := []struct { + keyword string + error_reason string + }{ + {"today", ""}, + {"tomorrow", ""}, + {"yesterday", ""}, + {"0_days_ago", ""}, + {"0_days_from_now_on", ""}, + {"32321_days_ago", ""}, + {"this_week", ""}, + {"last_week", ""}, + {"next_week", ""}, + {"5_weeks_ago", ""}, + {"3_weeks_from_now_on", ""}, + {"this_month", ""}, + {"next_month", ""}, + {"last_month", ""}, + {"2222_months_ago", ""}, + {"this_year", ""}, + {"last_year", ""}, + {"99_years_from_now_on", ""}, + {"toydayyyyy", "should give an error, as it cant transform today into a value like '0_days_ago"}, + {"0_days_agoooo", "should give an error, as it cant understand the temporal direction suffix, should have been written as 'ago'"}, + {"5_dayszzzz_ago", "should give an error, as it cant understand the base, should have been written as 'days' "}, + {"3.14_days_ago", "should give an error, as it cant understand multipler to move, should have been given as an integer "}, + } + + for _, test := range tests { + // Cant really test the results, as they are dynamic and depend on the time of the execution. + _, err := ParseDateKeyword(test.keyword) + if test.error_reason == "" { + if err != nil { + t.Errorf("input '%s' should have produced no errors, but it produced this error: %s", test.keyword, err) + } + } else { + if err == nil { + t.Errorf("input '%s' should have an error like this, but it did not produce any errors: %s", test.keyword, test.error_reason) + } + } + } + + today_keywords := []string{"today", "0_days_ago", "-0_days_ago", "0_days_from_now_on", "-0_days_from_now_on"} + todays_parsed := make([]time.Time, 0) + for _, keyword := range today_keywords { + parsed, err := ParseDateKeyword(keyword) + if err != nil { + t.Error(err) + } + todays_parsed = append(todays_parsed, parsed) + if len(todays_parsed) > 1 { + assert.Equal(t, todays_parsed[0], todays_parsed[len(todays_parsed)-1], "These parsed dates for 'today' should all match with each other.") + } + } +} + func TestUtilsIsFloatVal(t *testing.T) { tests := []struct { in float64 From 80fd14a7fb186d05099e5d9fca7be47f7e487bf8 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Wed, 12 Nov 2025 17:58:35 +0100 Subject: [PATCH 5/8] - Try to parse condition variable as a time string first, and jump directly to the next steps. - Fix a logic error in the time parser test - Make time parser test linux only - Trying to pass CI tests - Fix linter errors about function and variable names --- .golangci.yml | 2 + pkg/snclient/check_files_test.go | 24 ++++-- pkg/snclient/condition.go | 56 +++++++----- .../t/scripts/check_files_generate_files.sh | 2 +- pkg/utils/utils.go | 86 +++++++++---------- pkg/utils/utils_test.go | 21 +++-- 6 files changed, 105 insertions(+), 86 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index c5402252..5f808180 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -117,6 +117,8 @@ issues: - "Function 'buildListMacros' is too long" - "Function 'fetchProcs' has too many statements" - "Function 'ReplaceConditionals' has too many statements" + - "Function 'ParseDateKeyword' has too many statements" + - "Function 'expandUnitByType' has too many statements" - "cognitive complexity .* of func .*.Check" - "cognitive complexity .* of func .*conditionAdd" - "cognitive complexity .* of func .*.matchSingle" diff --git a/pkg/snclient/check_files_test.go b/pkg/snclient/check_files_test.go index d62fa362..1e2f0d17 100644 --- a/pkg/snclient/check_files_test.go +++ b/pkg/snclient/check_files_test.go @@ -1,3 +1,6 @@ +//go:build linux +// +build linux + package snclient import ( @@ -129,7 +132,7 @@ func TestCheckFilesNoPermission(t *testing.T) { StopTestAgent(t, snc) } -func check_files_configfile(t *testing.T, scriptsDir, scriptsType string) string { +func checkFilesConfigfile(t *testing.T, scriptsDir, scriptsType string) string { t.Helper() config := fmt.Sprintf(` @@ -158,16 +161,13 @@ check_files_generate_files = ./check_files_generate_files.EXTENSION "$ARG1$" } func TestTimeKeywordFilters(t *testing.T) { - // prepare a tempdir tempDir := t.TempDir() - fmt.Printf("tempDir= %s \n", tempDir) - testDir, _ := os.Getwd() scriptsDir := filepath.Join(testDir, "t", "scripts") - config := check_files_configfile(t, scriptsDir, "sh") + config := checkFilesConfigfile(t, scriptsDir, "sh") snc := StartTestAgent(t, config) // There is a bash script on this path: pkg/snclient/t/scripts/check_files_generate_files.sh @@ -175,6 +175,7 @@ func TestTimeKeywordFilters(t *testing.T) { // This script is added to the snclient config first and registered as a check command, then ran by the snclient executable itself res := snc.RunCheck("check_files_generate_files", []string{tempDir}) + // The script generates 11 files: // one_year_from_now_on // one_month_from_now_on // one_week_from_now_on @@ -186,17 +187,24 @@ func TestTimeKeywordFilters(t *testing.T) { // one_week_ago // one_month_ago // one_year_ago - assert.Equalf(t, CheckExitOK, res.State, "Generating test files successfull") - assert.Equalf(t, string(res.BuildPluginOutput()), "ok - Generated 11 files for testing", "output matches") + assert.Equalf(t, CheckExitOK, res.State, "Generating test files successful") + assert.Equalf(t, "ok - Generated 11 files for testing", string(res.BuildPluginOutput()), "output matches") // Note on 2025-11-06 : Multiple filter=""s are combined with a logical OR. // res = snc.RunCheck("check_files", []string{fmt.Sprintf("path=%s", tempDir), "filter=\"written>=today\"", "filter=\"written=today && written<=tomorrow\""}) assert.Equalf(t, CheckExitOK, res.State, "state OK") assert.Containsf(t, string(res.BuildPluginOutput()), "OK - All 1 files are ok", "output matches") + // Should get these five files, it cant get today because for that written==today + // tomorrow + // two_days_from_now_on + // one_week_from_now_on + // one_month_from_now_on + // one_year_from_now_on res = snc.RunCheck("check_files", []string{fmt.Sprintf("path=%s", tempDir), "filter=\"written>today\""}) assert.Equalf(t, CheckExitOK, res.State, "state OK") assert.Containsf(t, string(res.BuildPluginOutput()), "OK - All 5 files are ok", "output matches") diff --git a/pkg/snclient/condition.go b/pkg/snclient/condition.go index c91382de..c9a1a7a9 100644 --- a/pkg/snclient/condition.go +++ b/pkg/snclient/condition.go @@ -26,8 +26,6 @@ type Condition struct { // keyword is the original operand given in the condition keyword string - // some keywords can be transformed to another values during runtime. For example "today" can be transformed into todays UNIX timestamp. This directly influences the value attribute, so this is just a flag for debugging - keywordTransformed bool operator Operator value interface{} @@ -796,29 +794,36 @@ func (c *Condition) getUnit(keyword string) Unit { func (c *Condition) expandUnitByType(str string) error { match := reConditionValueUnit.FindStringSubmatch(str) - if len(match) >= 3 { - c.value = match[1] - c.unit = match[2] - } else { + // before doing the regex matching, try to parse it as a date keyword + var unit Unit + _, dateParsingError := utils.ParseDateKeyword(str) + if dateParsingError == nil { + // it can be parsed as a date c.value = str - // before returning null, check if it can be parsed as date - _, date_parsing_err := utils.ParseDateKeyword(str) - if date_parsing_err == nil { - // it can be parsed as a - c.value = str - } else { - // it cant be parsed as date as well - return fmt.Errorf("could not parse the condition operand: %s with regex, or as a date keyword with this error: %s", str, date_parsing_err.Error()) - } + unit = UDate + + goto parse_unit } + if len(match) < 3 { + c.value = str + + return nil + } + c.value = match[1] + c.unit = match[2] + // bytes value support % thresholds as well but we cannot expand them yet if c.unit == "%" { - return fmt.Errorf("parsed the condition operand: %s with regex, but the unit was determined as '%'. Expanding them is not implemented yet", str) + return nil + // might want to return an error? + // return fmt.Errorf("parsed the condition operand: %s with regex, but the unit was determined as '%s'. Expanding them is not implemented yet", str, c.unit) } // expand known units - unit := c.getUnit(c.keyword) + unit = c.getUnit(c.keyword) + +parse_unit: switch unit { case UByte: value, err := humanize.ParseBytes(str) @@ -830,21 +835,26 @@ func (c *Condition) expandUnitByType(str string) error { return nil case UDate: - value, duration_parse_error := utils.ExpandDuration(str) - if duration_parse_error == nil { + value, durationParseError := utils.ExpandDuration(str) + if durationParseError == nil { // The expandDuration parses the duration, not a specific date. If the user gives '10d', try to get the date from now on plus 10 days c.value = strconv.FormatFloat(float64(time.Now().Unix())+value, 'f', 0, 64) c.unit = "" + return nil } - parsed_time, date_parse_error := utils.ParseDateKeyword(str) - if date_parse_error == nil { + parsedTime, dateParseError := utils.ParseDateKeyword(str) + if dateParseError == nil { // Parse time keyword returns the specific time and not a difference. No need to add it to current date - c.value = float64(parsed_time.Unix()) + c.value = float64(parsedTime.Unix()) c.unit = "" + return nil } - return fmt.Errorf("Type of this conditional operand: %s was determined to be an UDate. It was not parsed as a duration, getting the error: %s . It was also not parsed as a specific date keyword, getting the error: %s", str, duration_parse_error.Error(), date_parse_error.Error()) + + return fmt.Errorf(`Type of this conditional operand: %s was determined to be an UDate. + It was not parsed as a duration, getting the error: %s . + It was also not parsed as a specific date keyword, getting the error: %s`, str, durationParseError.Error(), dateParseError.Error()) case UTimestamp: value, err := utils.ExpandDuration(str) if err != nil { diff --git a/pkg/snclient/t/scripts/check_files_generate_files.sh b/pkg/snclient/t/scripts/check_files_generate_files.sh index 407f9291..2531fed5 100755 --- a/pkg/snclient/t/scripts/check_files_generate_files.sh +++ b/pkg/snclient/t/scripts/check_files_generate_files.sh @@ -13,7 +13,7 @@ mkdir -p ${TESTING_DIR} TODAY=$(date -d 'today 00:00:00' +%F) -echo "${TODAY} ${TESTING_DIR}" > /tmp/asdasdasd +echo "${TODAY} ${TESTING_DIR}" > /tmp/check_files_generate_files ( cd ${TESTING_DIR} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 2aed67ab..e495553d 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -49,11 +49,11 @@ var TimeFactors = []struct { // The user can give a filter like 2_weeks_ago, 3_months_ago , 5_years_from_now_on etc. // The return values from this function refer to a specific time, e.g beginning of the week. It does not give the duration difference with current time -func ParseDateKeyword(time_keyword string) (time.Time, error) { - var time_keyword_being_processed string - var natural_language_conversion_ok bool - // convert words into the forms where the temporal direction, base and the numerical value can be parsed - natural_language_keywords := map[string]string{ +func ParseDateKeyword(timeKeyword string) (time.Time, error) { + var timeKeywordBeingProcessed string + var isAKnownTimeKeyword bool + // convert words into the forms that show the temporal direction, base and the numerical value to move + knownTimeKeywords := map[string]string{ "today": "0_days_ago", "tomorrow": "1_days_from_now_on", "yesterday": "1_days_ago", @@ -74,77 +74,77 @@ func ParseDateKeyword(time_keyword string) (time.Time, error) { "1_year_from_now_on": "1_years_from_now_on", } // this conversion may fail, but that is ok - if time_keyword_being_processed, natural_language_conversion_ok = natural_language_keywords[time_keyword]; !natural_language_conversion_ok { + if timeKeywordBeingProcessed, isAKnownTimeKeyword = knownTimeKeywords[timeKeyword]; !isAKnownTimeKeyword { // reset if assignment failed - time_keyword_being_processed = time_keyword + timeKeywordBeingProcessed = timeKeyword } - var temporal_backward, temporal_forward bool - time_keyword_being_processed, temporal_backward = strings.CutSuffix(time_keyword_being_processed, "_ago") - time_keyword_being_processed, temporal_forward = strings.CutSuffix(time_keyword_being_processed, "_from_now_on") - if temporal_backward == temporal_forward { - return time.Now(), fmt.Errorf("keyword: '%s' has to have survive exactly one suffix cuts of 'ago' and 'from_now_on'. Cannot ascertain temporal direction", time_keyword) + var temporalBackwardDirection, temporalForwardDirection bool + timeKeywordBeingProcessed, temporalBackwardDirection = strings.CutSuffix(timeKeywordBeingProcessed, "_ago") + timeKeywordBeingProcessed, temporalForwardDirection = strings.CutSuffix(timeKeywordBeingProcessed, "_from_now_on") + if temporalBackwardDirection == temporalForwardDirection { + return time.Now(), fmt.Errorf("keyword: '%s' has to have survive exactly one suffix cuts of 'ago' and 'from_now_on'. Cannot ascertain temporal direction", timeKeyword) } - // TODO: handle the timezone question? + // Set the timezone to UTC and get todays midnight according to UTC timezone := time.UTC now := time.Now().In(time.UTC) - today_midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, timezone) + todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, timezone) - var suffix_days_cut, suffix_weeks_cut, suffix_months_cut, suffix_years_cut bool + var suffixDaysCut, suffixWeeksCut, suffixMonthsCut, suffixYearsCut bool durationCutCount := 0 // Cant just convert the bool variables to int, golang does not allow it // Day based keywords use today as a basis, Week based keywords use this week as a basis. // We have to determine the beginning of that point, then move forward/backward - var t0 time.Time - if time_keyword_being_processed, suffix_days_cut = strings.CutSuffix(time_keyword_being_processed, "_days"); suffix_days_cut { + var time0 time.Time + if timeKeywordBeingProcessed, suffixDaysCut = strings.CutSuffix(timeKeywordBeingProcessed, "_days"); suffixDaysCut { durationCutCount++ - t0 = today_midnight + time0 = todayMidnight } - if time_keyword_being_processed, suffix_weeks_cut = strings.CutSuffix(time_keyword_being_processed, "_weeks"); suffix_weeks_cut { + if timeKeywordBeingProcessed, suffixWeeksCut = strings.CutSuffix(timeKeywordBeingProcessed, "_weeks"); suffixWeeksCut { durationCutCount++ // this should take values between 0 for Monday, and 6 for Sunday. // But the golang started Sunday at 0 and Monday at 1 - weekday := today_midnight.Weekday() - days_to_subtract := int(weekday) - int(time.Monday) - if days_to_subtract < 0 { - days_to_subtract += 7 // If today is Sunday (0), go back 6 days to Monday + weekday := todayMidnight.Weekday() + daysSinceWeekBeginning := int(weekday) - int(time.Monday) + if daysSinceWeekBeginning < 0 { + daysSinceWeekBeginning += 7 // If today is Sunday (0), subtraction gives -1 , add +7 to bring it back up to 6 } - t0 = today_midnight.AddDate(0, 0, -days_to_subtract) + time0 = todayMidnight.AddDate(0, 0, -daysSinceWeekBeginning) } - if time_keyword_being_processed, suffix_months_cut = strings.CutSuffix(time_keyword_being_processed, "_months"); suffix_months_cut { + if timeKeywordBeingProcessed, suffixMonthsCut = strings.CutSuffix(timeKeywordBeingProcessed, "_months"); suffixMonthsCut { durationCutCount++ - t0 = time.Date(today_midnight.Year(), today_midnight.Month(), 1, 0, 0, 0, 0, timezone) + time0 = time.Date(todayMidnight.Year(), todayMidnight.Month(), 1, 0, 0, 0, 0, timezone) } - if time_keyword_being_processed, suffix_years_cut = strings.CutSuffix(time_keyword_being_processed, "_years"); suffix_years_cut { + if timeKeywordBeingProcessed, suffixYearsCut = strings.CutSuffix(timeKeywordBeingProcessed, "_years"); suffixYearsCut { durationCutCount++ - t0 = time.Date(today_midnight.Year(), time.January, 1, 0, 0, 0, 0, timezone) + time0 = time.Date(todayMidnight.Year(), time.January, 1, 0, 0, 0, 0, timezone) } if durationCutCount != 1 { - return time.Now(), fmt.Errorf("keyword: '%s' has to have survive exactly one suffix cuts of 'days', 'weeks', 'months', 'years' ", time_keyword) + return time.Now(), fmt.Errorf("keyword: '%s' has to have survive exactly one suffix cuts of 'days', 'weeks', 'months', 'years' ", timeKeyword) } - move_count, move_count_ok := strconv.ParseInt(time_keyword_being_processed, 10, 32) - if move_count_ok != nil { - return time.Now(), fmt.Errorf("keyword: '%s' could not parse of the time basis to move front/backwards in time", time_keyword) + temporalMoveCount, temporalMoveCountParseOk := strconv.ParseInt(timeKeywordBeingProcessed, 10, 32) + if temporalMoveCountParseOk != nil { + return time.Now(), fmt.Errorf("keyword: '%s' could not parse of the time basis to move front/backwards in time", timeKeyword) } - if temporal_backward { - move_count *= -1 + if temporalBackwardDirection { + temporalMoveCount *= -1 } var result time.Time - if suffix_days_cut { - result = t0.AddDate(0, 0, int(move_count)) - } else if suffix_weeks_cut { - result = t0.AddDate(0, 0, 7*int(move_count)) - } else if suffix_months_cut { - result = t0.AddDate(0, int(move_count), 0) - } else if suffix_years_cut { - result = t0.AddDate(int(move_count), 0, 0) + switch { + case suffixDaysCut: + result = time0.AddDate(0, 0, int(temporalMoveCount)) + case suffixWeeksCut: + result = time0.AddDate(0, 0, 7*int(temporalMoveCount)) + case suffixMonthsCut: + result = time0.AddDate(0, int(temporalMoveCount), 0) + case suffixYearsCut: + result = time0.AddDate(int(temporalMoveCount), 0, 0) } return result, nil - } // ExpandDuration expand duration string into seconds , e.g '2h' -> 7200 diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 93dd3850..0f15bd61 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -30,10 +30,9 @@ func TestUtilsExpandDuration(t *testing.T) { } func TestUtilsParseDateKeyword(t *testing.T) { - tests := []struct { - keyword string - error_reason string + keyword string + errorReason string }{ {"today", ""}, {"tomorrow", ""}, @@ -62,27 +61,27 @@ func TestUtilsParseDateKeyword(t *testing.T) { for _, test := range tests { // Cant really test the results, as they are dynamic and depend on the time of the execution. _, err := ParseDateKeyword(test.keyword) - if test.error_reason == "" { + if test.errorReason == "" { if err != nil { t.Errorf("input '%s' should have produced no errors, but it produced this error: %s", test.keyword, err) } } else { if err == nil { - t.Errorf("input '%s' should have an error like this, but it did not produce any errors: %s", test.keyword, test.error_reason) + t.Errorf("input '%s' should have an error like this, but it did not produce any errors: %s", test.keyword, test.errorReason) } } } - today_keywords := []string{"today", "0_days_ago", "-0_days_ago", "0_days_from_now_on", "-0_days_from_now_on"} - todays_parsed := make([]time.Time, 0) - for _, keyword := range today_keywords { + keywordsMeaningToday := []string{"today", "0_days_ago", "-0_days_ago", "0_days_from_now_on", "-0_days_from_now_on"} + parsedKeywordsMeaningToday := make([]time.Time, 0) + for _, keyword := range keywordsMeaningToday { parsed, err := ParseDateKeyword(keyword) if err != nil { t.Error(err) } - todays_parsed = append(todays_parsed, parsed) - if len(todays_parsed) > 1 { - assert.Equal(t, todays_parsed[0], todays_parsed[len(todays_parsed)-1], "These parsed dates for 'today' should all match with each other.") + parsedKeywordsMeaningToday = append(parsedKeywordsMeaningToday, parsed) + if len(parsedKeywordsMeaningToday) > 1 { + assert.Equal(t, parsedKeywordsMeaningToday[0], parsedKeywordsMeaningToday[len(parsedKeywordsMeaningToday)-1], "These parsed dates for 'today' should all match with each other.") } } } From a76ec0ae125c7dea3ddd35a4239463aeb3b7318b Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Thu, 13 Nov 2025 09:59:02 +0100 Subject: [PATCH 6/8] - Trying to fix the test failing in CI pipeline only, it does not fail locally. The CI pipeline might be messing with the folder. --- pkg/snclient/check_files_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/snclient/check_files_test.go b/pkg/snclient/check_files_test.go index 1e2f0d17..128c8c2a 100644 --- a/pkg/snclient/check_files_test.go +++ b/pkg/snclient/check_files_test.go @@ -190,6 +190,14 @@ func TestTimeKeywordFilters(t *testing.T) { assert.Equalf(t, CheckExitOK, res.State, "Generating test files successful") assert.Equalf(t, "ok - Generated 11 files for testing", string(res.BuildPluginOutput()), "output matches") + // This will be printed if the test fails. + t.Logf("Contents of test directory %s:", tempDir) + files, _ := os.ReadDir(tempDir) + for _, file := range files { + info, _ := file.Info() + t.Logf("- File: %s, ModTime: %s", file.Name(), info.ModTime().Format(time.RFC3339)) + } + // Note on 2025-11-06 : Multiple filter=""s are combined with a logical OR. // res = snc.RunCheck("check_files", []string{fmt.Sprintf("path=%s", tempDir), "filter=\"written>=today\"", "filter=\"written Date: Thu, 13 Nov 2025 11:53:48 +0100 Subject: [PATCH 7/8] - add a timezone to the condition struct. propagate it from check to the condition - the date parsing now uses this timezone when determining times - stop using time.UTC or time.Local except for testing. --- pkg/snclient/check_files_test.go | 4 ++-- pkg/snclient/checkdata.go | 16 +++++++-------- pkg/snclient/checkresult.go | 3 ++- pkg/snclient/condition.go | 12 +++++++---- pkg/snclient/condition_test.go | 15 +++++++------- pkg/snclient/macro_test.go | 4 ++-- pkg/snclient/macros.go | 8 ++++---- .../t/scripts/check_files_generate_files.sh | 1 + pkg/utils/utils.go | 20 +++++++++++-------- pkg/utils/utils_test.go | 4 ++-- 10 files changed, 49 insertions(+), 38 deletions(-) diff --git a/pkg/snclient/check_files_test.go b/pkg/snclient/check_files_test.go index 128c8c2a..4afd18e3 100644 --- a/pkg/snclient/check_files_test.go +++ b/pkg/snclient/check_files_test.go @@ -202,8 +202,8 @@ func TestTimeKeywordFilters(t *testing.T) { // res = snc.RunCheck("check_files", []string{fmt.Sprintf("path=%s", tempDir), "filter=\"written>=today\"", "filter=\"written=today && written<=tomorrow\""}) + // combine the two conditions, filters only to the single 'today' file that is written after today midnight and earlier then tomorrow midnight + res = snc.RunCheck("check_files", []string{fmt.Sprintf("path=%s", tempDir), "filter=\"written>=today && written %v", check.input, check.expect) @@ -113,7 +114,7 @@ func TestConditionParseErrors(t *testing.T) { {"state in ("}, {"a > 0 && b < 0 || x > 3"}, } { - cond, err := NewCondition(check.threshold, nil) + cond, err := NewCondition(check.threshold, nil, time.UTC) require.Errorf(t, err, "ConditionParse should error") assert.Nilf(t, cond, "ConditionParse(%s) errors should not return condition", check.threshold) } @@ -179,7 +180,7 @@ func TestConditionCompare(t *testing.T) { {"test slike 'Blah'", "test", "blah", false, true}, {"test like str(blah)", "test", "blah", true, true}, } { - threshold, err := NewCondition(check.threshold, nil) + threshold, err := NewCondition(check.threshold, nil, time.UTC) require.NoErrorf(t, err, "parsed threshold") assert.NotNilf(t, threshold, "parsed threshold") compare := map[string]string{check.key: check.value} @@ -201,7 +202,7 @@ func TestConditionThresholdString(t *testing.T) { {"test > 10 and test < 20", "test", "@10:20"}, {"test < 20 and test > 10", "test", "@10:20"}, } { - threshold, err := NewCondition(check.threshold, nil) + threshold, err := NewCondition(check.threshold, nil, time.UTC) require.NoErrorf(t, err, "parsed threshold") assert.NotNilf(t, threshold, "parsed threshold") perfRange := ThresholdString([]string{check.name}, ConditionList{threshold}, convert.Num2String) @@ -222,7 +223,7 @@ func TestConditionPreCheck(t *testing.T) { {filterStr, map[string]string{"name": "none", "state": "running"}, false, false}, {filterStr, map[string]string{"test": "", "xyz": ""}, true, false}, } { - cond, err := NewCondition(check.filter, nil) + cond, err := NewCondition(check.filter, nil, time.UTC) require.NoError(t, err) chk := CheckData{} ok := chk.MatchMapCondition(ConditionList{cond}, check.entry, true) @@ -232,7 +233,7 @@ func TestConditionPreCheck(t *testing.T) { assert.Equalf(t, check.expectPre, ok, "final check on %v returned: %v", check.entry, ok) // none filter - cond, _ = NewCondition("none", nil) + cond, _ = NewCondition("none", nil, time.UTC) ok = chk.MatchMapCondition(ConditionList{cond}, check.entry, true) assert.Truef(t, ok, "none pre check on %v returned: %v", check.entry, ok) @@ -243,7 +244,7 @@ func TestConditionPreCheck(t *testing.T) { func TestConditionAlias(t *testing.T) { filterStr := `( name = 'xinetd' or name like 'other' ) and state = 'started'` - cond, err := NewCondition(filterStr, nil) + cond, err := NewCondition(filterStr, nil, time.UTC) require.NoError(t, err) check := &CheckData{ diff --git a/pkg/snclient/macro_test.go b/pkg/snclient/macro_test.go index 30c76d6d..8c0334b9 100644 --- a/pkg/snclient/macro_test.go +++ b/pkg/snclient/macro_test.go @@ -103,7 +103,7 @@ func TestMacroConditionals(t *testing.T) { } for _, tst := range tests { - res, err := ReplaceConditionals(tst.In, macros) + res, err := ReplaceConditionals(tst.In, time.UTC, macros) require.NoError(t, err) res = ReplaceMacros(res, nil, macros) assert.Equalf(t, tst.Expect, res, "replacing: %s", tst.In) @@ -135,7 +135,7 @@ func TestMacroConditionalsMulti(t *testing.T) { } for _, tst := range tests { - res, err := ReplaceConditionals(tst.In, macros...) + res, err := ReplaceConditionals(tst.In, time.UTC, macros...) require.NoError(t, err) res = ReplaceMacros(res, nil, macros...) assert.Equalf(t, tst.Expect, res, "replacing: %s", tst.In) diff --git a/pkg/snclient/macros.go b/pkg/snclient/macros.go index 0eeedc8e..cb561416 100644 --- a/pkg/snclient/macros.go +++ b/pkg/snclient/macros.go @@ -39,7 +39,7 @@ var ( // ReplaceTemplate combines ReplaceConditionals and ReplaceMacros func ReplaceTemplate(value string, timezone *time.Location, macroSets ...map[string]string) (string, error) { - expanded, err := ReplaceConditionals(value, macroSets...) + expanded, err := ReplaceConditionals(value, timezone, macroSets...) if err != nil { return expanded, err } @@ -51,7 +51,7 @@ func ReplaceTemplate(value string, timezone *time.Location, macroSets ...map[str /* ReplaceConditionals replaces conditionals of the form * {{ IF condition }}...{{ ELSIF condition }}...{{ ELSE }}...{{ END }}" */ -func ReplaceConditionals(value string, macroSets ...map[string]string) (string, error) { +func ReplaceConditionals(value string, timezone *time.Location, macroSets ...map[string]string) (string, error) { splitBy := map[string]string{ "{{": "}}", } @@ -85,7 +85,7 @@ func ReplaceConditionals(value string, macroSets ...map[string]string) (string, if len(fields) < 2 { return value, fmt.Errorf("missing condition in %s clause :%s", strings.ToUpper(fields[0]), piece) } - condition, err := NewCondition(fields[1], nil) + condition, err := NewCondition(fields[1], nil, timezone) if err != nil { return value, fmt.Errorf("parsing condition in %s failed: %s", fields[1], err.Error()) } @@ -105,7 +105,7 @@ func ReplaceConditionals(value string, macroSets ...map[string]string) (string, break } - condition, err := NewCondition(fields[1], nil) + condition, err := NewCondition(fields[1], nil, timezone) if err != nil { return value, fmt.Errorf("parsing condition in %s failed: %s", fields[1], err.Error()) } diff --git a/pkg/snclient/t/scripts/check_files_generate_files.sh b/pkg/snclient/t/scripts/check_files_generate_files.sh index 2531fed5..deb3d096 100755 --- a/pkg/snclient/t/scripts/check_files_generate_files.sh +++ b/pkg/snclient/t/scripts/check_files_generate_files.sh @@ -11,6 +11,7 @@ TESTING_DIR="$1" mkdir -p ${TESTING_DIR} +# Generate it with local timezone TODAY=$(date -d 'today 00:00:00' +%F) echo "${TODAY} ${TESTING_DIR}" > /tmp/check_files_generate_files diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index e495553d..0b89ac33 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -49,7 +49,7 @@ var TimeFactors = []struct { // The user can give a filter like 2_weeks_ago, 3_months_ago , 5_years_from_now_on etc. // The return values from this function refer to a specific time, e.g beginning of the week. It does not give the duration difference with current time -func ParseDateKeyword(timeKeyword string) (time.Time, error) { +func ParseDateKeyword(timeKeyword string, timezone *time.Location) (time.Time, error) { var timeKeywordBeingProcessed string var isAKnownTimeKeyword bool // convert words into the forms that show the temporal direction, base and the numerical value to move @@ -86,11 +86,15 @@ func ParseDateKeyword(timeKeyword string) (time.Time, error) { return time.Now(), fmt.Errorf("keyword: '%s' has to have survive exactly one suffix cuts of 'ago' and 'from_now_on'. Cannot ascertain temporal direction", timeKeyword) } - // Set the timezone to UTC and get todays midnight according to UTC - timezone := time.UTC - - now := time.Now().In(time.UTC) - todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, timezone) + // Need to specify a timezone. This usually comes from the Condition object, which is set by the Check + var timezoneToUse time.Location + if timezone != nil { + timezoneToUse = *timezone + } else { + timezoneToUse = *time.UTC + } + now := time.Now().In(&timezoneToUse) + todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, &timezoneToUse) var suffixDaysCut, suffixWeeksCut, suffixMonthsCut, suffixYearsCut bool durationCutCount := 0 // Cant just convert the bool variables to int, golang does not allow it @@ -114,11 +118,11 @@ func ParseDateKeyword(timeKeyword string) (time.Time, error) { } if timeKeywordBeingProcessed, suffixMonthsCut = strings.CutSuffix(timeKeywordBeingProcessed, "_months"); suffixMonthsCut { durationCutCount++ - time0 = time.Date(todayMidnight.Year(), todayMidnight.Month(), 1, 0, 0, 0, 0, timezone) + time0 = time.Date(todayMidnight.Year(), todayMidnight.Month(), 1, 0, 0, 0, 0, &timezoneToUse) } if timeKeywordBeingProcessed, suffixYearsCut = strings.CutSuffix(timeKeywordBeingProcessed, "_years"); suffixYearsCut { durationCutCount++ - time0 = time.Date(todayMidnight.Year(), time.January, 1, 0, 0, 0, 0, timezone) + time0 = time.Date(todayMidnight.Year(), time.January, 1, 0, 0, 0, 0, &timezoneToUse) } if durationCutCount != 1 { return time.Now(), fmt.Errorf("keyword: '%s' has to have survive exactly one suffix cuts of 'days', 'weeks', 'months', 'years' ", timeKeyword) diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 0f15bd61..835dcedd 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -60,7 +60,7 @@ func TestUtilsParseDateKeyword(t *testing.T) { for _, test := range tests { // Cant really test the results, as they are dynamic and depend on the time of the execution. - _, err := ParseDateKeyword(test.keyword) + _, err := ParseDateKeyword(test.keyword, time.UTC) if test.errorReason == "" { if err != nil { t.Errorf("input '%s' should have produced no errors, but it produced this error: %s", test.keyword, err) @@ -75,7 +75,7 @@ func TestUtilsParseDateKeyword(t *testing.T) { keywordsMeaningToday := []string{"today", "0_days_ago", "-0_days_ago", "0_days_from_now_on", "-0_days_from_now_on"} parsedKeywordsMeaningToday := make([]time.Time, 0) for _, keyword := range keywordsMeaningToday { - parsed, err := ParseDateKeyword(keyword) + parsed, err := ParseDateKeyword(keyword, time.UTC) if err != nil { t.Error(err) } From f34fd52748aec5a48ab81861c02eaa047c010747 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Thu, 20 Nov 2025 17:18:47 +0100 Subject: [PATCH 8/8] - move the time keyword check_files test to its own source file. It only runs on linux now. --- pkg/snclient/check_files_test.go | 91 ----------------- .../check_files_time_keywords_test.go | 99 +++++++++++++++++++ pkg/snclient/condition_test.go | 2 +- 3 files changed, 100 insertions(+), 92 deletions(-) create mode 100644 pkg/snclient/check_files_time_keywords_test.go diff --git a/pkg/snclient/check_files_test.go b/pkg/snclient/check_files_test.go index 4afd18e3..5dfab750 100644 --- a/pkg/snclient/check_files_test.go +++ b/pkg/snclient/check_files_test.go @@ -1,6 +1,3 @@ -//go:build linux -// +build linux - package snclient import ( @@ -131,91 +128,3 @@ func TestCheckFilesNoPermission(t *testing.T) { StopTestAgent(t, snc) } - -func checkFilesConfigfile(t *testing.T, scriptsDir, scriptsType string) string { - t.Helper() - - config := fmt.Sprintf(` -[/modules] -CheckExternalScripts = enabled - -[/paths] -scripts = %s -shared-path = %%(scripts) - -[/settings/external scripts/wrappings] -sh = %%SCRIPT%% %%ARGS%% -exe = %%SCRIPT%% %%ARGS%% - -[/settings/external scripts] -timeout = 1111111 -allow arguments = true - -[/settings/external scripts/scripts] -check_files_generate_files = ./check_files_generate_files.EXTENSION "$ARG1$" -`, scriptsDir) - - config = strings.ReplaceAll(config, "EXTENSION", scriptsType) - - return config -} - -func TestTimeKeywordFilters(t *testing.T) { - // prepare a tempdir - tempDir := t.TempDir() - - testDir, _ := os.Getwd() - scriptsDir := filepath.Join(testDir, "t", "scripts") - - config := checkFilesConfigfile(t, scriptsDir, "sh") - snc := StartTestAgent(t, config) - - // There is a bash script on this path: pkg/snclient/t/scripts/check_files_generate_files.sh - // It generates files on a temporary path, and changes their modification date - // This script is added to the snclient config first and registered as a check command, then ran by the snclient executable itself - res := snc.RunCheck("check_files_generate_files", []string{tempDir}) - - // The script generates 11 files: - // one_year_from_now_on - // one_month_from_now_on - // one_week_from_now_on - // two_days_from_now_on - // tomorrow - // today - // yesterday - // two_days_ago - // one_week_ago - // one_month_ago - // one_year_ago - assert.Equalf(t, CheckExitOK, res.State, "Generating test files successful") - assert.Equalf(t, "ok - Generated 11 files for testing", string(res.BuildPluginOutput()), "output matches") - - // This will be printed if the test fails. - t.Logf("Contents of test directory %s:", tempDir) - files, _ := os.ReadDir(tempDir) - for _, file := range files { - info, _ := file.Info() - t.Logf("- File: %s, ModTime: %s", file.Name(), info.ModTime().Format(time.RFC3339)) - } - - // Note on 2025-11-06 : Multiple filter=""s are combined with a logical OR. - // res = snc.RunCheck("check_files", []string{fmt.Sprintf("path=%s", tempDir), "filter=\"written>=today\"", "filter=\"written=today && writtentoday\""}) - assert.Equalf(t, CheckExitOK, res.State, "state OK") - assert.Containsf(t, string(res.BuildPluginOutput()), "OK - All 5 files are ok", "output matches") - - StopTestAgent(t, snc) -} diff --git a/pkg/snclient/check_files_time_keywords_test.go b/pkg/snclient/check_files_time_keywords_test.go new file mode 100644 index 00000000..17084cd9 --- /dev/null +++ b/pkg/snclient/check_files_time_keywords_test.go @@ -0,0 +1,99 @@ +//go:build linux +// +build linux + +package snclient + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func checkFilesConfigfile(t *testing.T, scriptsDir, scriptsType string) string { + t.Helper() + + config := fmt.Sprintf(` +[/modules] +CheckExternalScripts = enabled +[/paths] +scripts = %s +shared-path = %%(scripts) +[/settings/external scripts/wrappings] +sh = %%SCRIPT%% %%ARGS%% +exe = %%SCRIPT%% %%ARGS%% +[/settings/external scripts] +timeout = 1111111 +allow arguments = true +[/settings/external scripts/scripts] +check_files_generate_files = ./check_files_generate_files.EXTENSION "$ARG1$" +`, scriptsDir) + + config = strings.ReplaceAll(config, "EXTENSION", scriptsType) + + return config +} + +func TestTimeKeywordFilters(t *testing.T) { + // prepare a tempdir + tempDir := t.TempDir() + + testDir, _ := os.Getwd() + scriptsDir := filepath.Join(testDir, "t", "scripts") + + config := checkFilesConfigfile(t, scriptsDir, "sh") + snc := StartTestAgent(t, config) + + // There is a bash script on this path: pkg/snclient/t/scripts/check_files_generate_files.sh + // It generates files on a temporary path, and changes their modification date + // This script is added to the snclient config first and registered as a check command, then ran by the snclient executable itself + res := snc.RunCheck("check_files_generate_files", []string{tempDir}) + + // The script generates 11 files: + // one_year_from_now_on + // one_month_from_now_on + // one_week_from_now_on + // two_days_from_now_on + // tomorrow + // today + // yesterday + // two_days_ago + // one_week_ago + // one_month_ago + // one_year_ago + assert.Equalf(t, CheckExitOK, res.State, "Generating test files successful") + assert.Equalf(t, "ok - Generated 11 files for testing", string(res.BuildPluginOutput()), "output matches") + + // This will be printed if the test fails. + t.Logf("Contents of test directory %s:", tempDir) + files, _ := os.ReadDir(tempDir) + for _, file := range files { + info, _ := file.Info() + t.Logf("- File: %s, ModTime: %s", file.Name(), info.ModTime().Format(time.RFC3339)) + } + + // Note on 2025-11-06 : Multiple filter=""s are combined with a logical OR. + // res = snc.RunCheck("check_files", []string{fmt.Sprintf("path=%s", tempDir), "filter=\"written>=today\"", "filter=\"written=today && writtentoday\""}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + assert.Containsf(t, string(res.BuildPluginOutput()), "OK - All 5 files are ok", "output matches") + + StopTestAgent(t, snc) +} diff --git a/pkg/snclient/condition_test.go b/pkg/snclient/condition_test.go index f70c8163..0d8f4b6a 100644 --- a/pkg/snclient/condition_test.go +++ b/pkg/snclient/condition_test.go @@ -263,7 +263,7 @@ func TestConditionAlias(t *testing.T) { func TestConditionColAlias(t *testing.T) { filterStr := `( name = 'xinetd' and name unlike 'other' ) and state = 'started'` - cond, err := NewCondition(filterStr, nil) + cond, err := NewCondition(filterStr, nil, time.UTC) require.NoError(t, err) check := &CheckData{