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_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/checkdata.go b/pkg/snclient/checkdata.go index 704f9dd7..880257c9 100644 --- a/pkg/snclient/checkdata.go +++ b/pkg/snclient/checkdata.go @@ -626,7 +626,7 @@ func (cd *CheckData) parseArgs(args []string) (argList []Argument, err error) { return nil, nil case "ok": - cond, err2 := NewCondition(argValue, &cd.attributes) + cond, err2 := NewCondition(argValue, &cd.attributes, cd.timezone) if err2 != nil { return nil, err2 } @@ -638,7 +638,7 @@ func (cd *CheckData) parseArgs(args []string) (argList []Argument, err error) { } cd.warnThreshold = warn case "warn", "warning": - cond, err2 := NewCondition(argValue, &cd.attributes) + cond, err2 := NewCondition(argValue, &cd.attributes, cd.timezone) if err2 != nil { return nil, err2 } @@ -650,7 +650,7 @@ func (cd *CheckData) parseArgs(args []string) (argList []Argument, err error) { } cd.critThreshold = crit case "crit", "critical": - cond, err2 := NewCondition(argValue, &cd.attributes) + cond, err2 := NewCondition(argValue, &cd.attributes, cd.timezone) if err2 != nil { return nil, err2 } @@ -664,7 +664,7 @@ func (cd *CheckData) parseArgs(args []string) (argList []Argument, err error) { cd.filter = filter case "filter": applyDefaultFilter = false - cond, err2 := NewCondition(argValue, &cd.attributes) + cond, err2 := NewCondition(argValue, &cd.attributes, cd.timezone) if err2 != nil { return nil, err2 } @@ -909,7 +909,7 @@ func (cd *CheckData) removeQuotes(str string) string { // setFallbacks sets default filter/warn/crit thresholds unless already set. func (cd *CheckData) setFallbacks(applyDefaultFilter bool, defaultWarning, defaultCritical string) error { if applyDefaultFilter && cd.defaultFilter != "" { - cond, err := NewCondition(cd.defaultFilter, &cd.attributes) + cond, err := NewCondition(cd.defaultFilter, &cd.attributes, cd.timezone) if err != nil { return err } @@ -1580,7 +1580,7 @@ func (cd *CheckData) applyDefaultThreshold(defaultThreshold string, list Conditi return list } - condDef, err := NewCondition(defaultThreshold, &cd.attributes) + condDef, err := NewCondition(defaultThreshold, &cd.attributes, cd.timezone) if err != nil { log.Errorf("default threshold: %s", defaultThreshold) log.Panicf("default threshold failed: %s", err.Error()) @@ -1596,7 +1596,7 @@ func (cd *CheckData) appendDefaultThreshold(keyword, condStr, defaultThreshold s return nil, fmt.Errorf("keyword %s= cannot be used multiple times", keyword) } - cond, err := NewCondition(condStr, &cd.attributes) + cond, err := NewCondition(condStr, &cd.attributes, cd.timezone) if err != nil { return nil, err } @@ -1607,7 +1607,7 @@ func (cd *CheckData) appendDefaultThreshold(keyword, condStr, defaultThreshold s return list, nil } - condDef, err := NewCondition(defaultThreshold, &cd.attributes) + condDef, err := NewCondition(defaultThreshold, &cd.attributes, cd.timezone) if err != nil { log.Errorf("default threshold: %s", defaultThreshold) log.Panicf("default threshold failed: %s", err.Error()) diff --git a/pkg/snclient/checkresult.go b/pkg/snclient/checkresult.go index 98a5fe2f..f5243dc9 100644 --- a/pkg/snclient/checkresult.go +++ b/pkg/snclient/checkresult.go @@ -47,7 +47,8 @@ func (cr *CheckResult) Finalize(timezone *time.Location, macros ...map[string]st log.Debugf("replacing template failed: %s: %s", cr.Output, err.Error()) } cr.Output = output - details, err := ReplaceConditionals(cr.Details, macroSet...) + // cannot get timezone of the conditionals here + details, err := ReplaceConditionals(cr.Details, nil, macroSet...) if err != nil { log.Debugf("replacing details template failed: %s: %s", cr.Details, err.Error()) } diff --git a/pkg/snclient/condition.go b/pkg/snclient/condition.go index e6b37ed1..1994466f 100644 --- a/pkg/snclient/condition.go +++ b/pkg/snclient/condition.go @@ -24,7 +24,9 @@ var ( type Condition struct { noCopy noCopy - keyword string + // keyword is the original operand given in the condition + keyword string + operator Operator value interface{} unit string @@ -39,6 +41,9 @@ type Condition struct { // store initial string original string + // timezone for the conditionm used when discerning date values + timezone *time.Location + // reference to check attributes (used to expand by unit) attr *[]CheckAttribute } @@ -186,10 +191,10 @@ func GroupOperatorParse(str string) (GroupOperator, error) { } // NewCondition parse filter= from check args -func NewCondition(input string, attr *[]CheckAttribute) (*Condition, error) { +func NewCondition(input string, attr *[]CheckAttribute, timezone *time.Location) (*Condition, error) { input = strings.TrimSpace(input) if input == "none" { - return &Condition{isNone: true, original: input, attr: attr}, nil + return &Condition{isNone: true, original: input, attr: attr, timezone: timezone}, nil } token := utils.Tokenize(replaceStrOp(input)) @@ -314,58 +319,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 { @@ -493,6 +518,7 @@ func (c *Condition) Clone() *Condition { groupOperator: c.groupOperator, group: make(ConditionList, 0), attr: c.attr, + timezone: c.timezone, original: c.original, } @@ -772,6 +798,18 @@ func (c *Condition) getUnit(keyword string) Unit { func (c *Condition) expandUnitByType(str string) error { match := reConditionValueUnit.FindStringSubmatch(str) + + // before doing the regex matching, try to parse it as a date keyword + var unit Unit + _, dateParsingError := utils.ParseDateKeyword(str, c.timezone) + if dateParsingError == nil { + // it can be parsed as a date + c.value = str + unit = UDate + + goto parse_unit + } + if len(match) < 3 { c.value = str @@ -783,24 +821,49 @@ func (c *Condition) expandUnitByType(str string) error { // bytes value support % thresholds as well but we cannot expand them yet if c.unit == "%" { 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) 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, UTimestamp: + case UDate: + 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 + } + parsedTime, dateParseError := utils.ParseDateKeyword(str, c.timezone) + 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(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, durationParseError.Error(), dateParseError.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 = "" @@ -809,7 +872,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/snclient/condition_test.go b/pkg/snclient/condition_test.go index 7feb48db..0d8f4b6a 100644 --- a/pkg/snclient/condition_test.go +++ b/pkg/snclient/condition_test.go @@ -2,6 +2,7 @@ package snclient import ( "testing" + "time" "github.com/consol-monitoring/snclient/pkg/convert" "github.com/stretchr/testify/assert" @@ -65,7 +66,7 @@ func TestConditionParse(t *testing.T) { }, }, } { - cond, err := NewCondition(check.input, nil) + cond, err := NewCondition(check.input, nil, nil) check.expect.original = check.input require.NoErrorf(t, err, "ConditionParse should throw no error") assert.Equal(t, check.expect, cond, "ConditionParse(%s) -> %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{ @@ -262,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{ 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 new file mode 100755 index 00000000..deb3d096 --- /dev/null +++ b/pkg/snclient/t/scripts/check_files_generate_files.sh @@ -0,0 +1,40 @@ +#!/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} + +# Generate it with local timezone +TODAY=$(date -d 'today 00:00:00' +%F) + +echo "${TODAY} ${TESTING_DIR}" > /tmp/check_files_generate_files + +( + 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 + diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 0629d837..0b89ac33 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 @@ -46,7 +47,111 @@ var TimeFactors = []struct { {"y", 86400 * 365}, } -// ExpandDuration expand duration string into seconds +// 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, 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 + knownTimeKeywords := map[string]string{ + "today": "0_days_ago", + "tomorrow": "1_days_from_now_on", + "yesterday": "1_days_ago", + "this_week": "0_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", + "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", + } + // this conversion may fail, but that is ok + if timeKeywordBeingProcessed, isAKnownTimeKeyword = knownTimeKeywords[timeKeyword]; !isAKnownTimeKeyword { + // reset if assignment failed + timeKeywordBeingProcessed = timeKeyword + } + + 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) + } + + // 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 + // 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 time0 time.Time + if timeKeywordBeingProcessed, suffixDaysCut = strings.CutSuffix(timeKeywordBeingProcessed, "_days"); suffixDaysCut { + durationCutCount++ + time0 = todayMidnight + } + 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 := 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 + } + time0 = todayMidnight.AddDate(0, 0, -daysSinceWeekBeginning) + } + if timeKeywordBeingProcessed, suffixMonthsCut = strings.CutSuffix(timeKeywordBeingProcessed, "_months"); suffixMonthsCut { + durationCutCount++ + 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, &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) + } + + 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 temporalBackwardDirection { + temporalMoveCount *= -1 + } + + var result time.Time + 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 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..835dcedd 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -29,6 +29,63 @@ func TestUtilsExpandDuration(t *testing.T) { } } +func TestUtilsParseDateKeyword(t *testing.T) { + tests := []struct { + keyword string + errorReason 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, 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) + } + } 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.errorReason) + } + } + } + + 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, time.UTC) + if err != nil { + t.Error(err) + } + 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.") + } + } +} + func TestUtilsIsFloatVal(t *testing.T) { tests := []struct { in float64