From 7ed9a31981d2c8c74cbafb28b636192cf4b93e07 Mon Sep 17 00:00:00 2001 From: Tomasz Smelcerz Date: Fri, 3 Jan 2025 13:04:34 +0100 Subject: [PATCH 1/4] feat: Maintenance Windows resolver module (#2155) * Add the module * Extend linting in the Makefile * Add README.md * Extend linting action --- .github/workflows/lint-golangci.yml | 6 + Makefile | 3 +- maintenancewindows/README.md | 6 + maintenancewindows/go.mod | 11 + maintenancewindows/go.sum | 10 + maintenancewindows/resolver/maintwindow.go | 396 ++++++++++++++++++ .../resolver/maintwindow_test.go | 325 ++++++++++++++ maintenancewindows/resolver/resolver.go | 64 +++ maintenancewindows/resolver/resolver_test.go | 26 ++ .../resolver/testdata/ruleset-1.json | 103 +++++ .../resolver/testdata/ruleset-2.json | 1 + .../resolver/testdata/ruleset-3.yaml | 5 + 12 files changed, 955 insertions(+), 1 deletion(-) create mode 100644 maintenancewindows/README.md create mode 100644 maintenancewindows/go.mod create mode 100644 maintenancewindows/go.sum create mode 100644 maintenancewindows/resolver/maintwindow.go create mode 100644 maintenancewindows/resolver/maintwindow_test.go create mode 100644 maintenancewindows/resolver/resolver.go create mode 100644 maintenancewindows/resolver/resolver_test.go create mode 100644 maintenancewindows/resolver/testdata/ruleset-1.json create mode 100644 maintenancewindows/resolver/testdata/ruleset-2.json create mode 100644 maintenancewindows/resolver/testdata/ruleset-3.yaml diff --git a/.github/workflows/lint-golangci.yml b/.github/workflows/lint-golangci.yml index 184bb48116..dd54813e8f 100644 --- a/.github/workflows/lint-golangci.yml +++ b/.github/workflows/lint-golangci.yml @@ -27,3 +27,9 @@ jobs: version: v1.60.3 args: --verbose working-directory: ./api + - name: golangci-lint for maintenancewindows module + uses: golangci/golangci-lint-action@v6.1.0 + with: + version: v1.60.3 + args: --verbose + working-directory: ./maintenancewindows diff --git a/Makefile b/Makefile index bcee924108..61a6a3cf27 100644 --- a/Makefile +++ b/Makefile @@ -174,4 +174,5 @@ fmt: ## Run go fmt against code. lint: ## Run golangci-lint against code. GOBIN=$(LOCALBIN) go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANG_CI_LINT_VERSION) $(LOCALBIN)/golangci-lint run --verbose -c .golangci.yaml - cd api && $(LOCALBIN)/golangci-lint run --verbose -c ../.golangci.yaml + pushd api; $(LOCALBIN)/golangci-lint run --verbose -c ../.golangci.yaml; popd + pushd maintenancewindows; $(LOCALBIN)/golangci-lint run --verbose -c ../.golangci.yaml; popd diff --git a/maintenancewindows/README.md b/maintenancewindows/README.md new file mode 100644 index 0000000000..77e8dfc83f --- /dev/null +++ b/maintenancewindows/README.md @@ -0,0 +1,6 @@ +# Maitenance Windows Library for Kyma + +## Overview + +This module contains the code for calculating the maintenance windows during which a Kyma cluster can be updated. [Lifecycle Manager](https://github.com/kyma-project/lifecycle-manager) and other components of the Kyma Control Plane use this maintenance windows library. + diff --git a/maintenancewindows/go.mod b/maintenancewindows/go.mod new file mode 100644 index 0000000000..b7bf9d089c --- /dev/null +++ b/maintenancewindows/go.mod @@ -0,0 +1,11 @@ +module github.com/kyma-project/lifecycle-manager/maintenancewindows + +go 1.23.4 + +require github.com/stretchr/testify v1.10.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/maintenancewindows/go.sum b/maintenancewindows/go.sum new file mode 100644 index 0000000000..713a0b4f0a --- /dev/null +++ b/maintenancewindows/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/maintenancewindows/resolver/maintwindow.go b/maintenancewindows/resolver/maintwindow.go new file mode 100644 index 0000000000..e2d1317b0e --- /dev/null +++ b/maintenancewindows/resolver/maintwindow.go @@ -0,0 +1,396 @@ +package resolver + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "reflect" + "regexp" + "slices" + "time" +) + +const ( + timeOnlyFormat = "15:04:05Z07:00" + time24Hours = 24 * time.Hour +) + +var ( + ErrPolicyNotExists = errors.New("maintenance policy doesn't exist") + ErrUnknownOption = errors.New("unknown option") + ErrNoWindowInPolicies = errors.New("matched policies did not provide a window") + ErrNoWindowFound = errors.New("matches and defaults also failed to provide a window") + ErrJSONUnmarshal = errors.New("error during unmarshal") +) + +type ResolvedWindow struct { + Begin time.Time + End time.Time +} + +func (rw ResolvedWindow) String() string { + return fmt.Sprintf("", rw.Begin, rw.End) +} + +type MaintenanceWindowPolicy struct { + Rules []MaintenancePolicyRule `json:"rules"` + Default MaintenanceWindow `json:"default"` +} + +// options. +type resolveOptions struct { + time time.Time + ongoing bool + minDuration time.Duration + firstMatchOnly bool + fallbackDefault bool +} + +// Specify the time to calculate with. +type TimeStamp time.Time + +// Take ongoing windows into account. +type OngoingWindow bool + +// If taking ongoing windows into account, minimum duration. +type MinWindowSize time.Duration + +// Whether stop at first matched policy's windows. +type FirstMatchOnly bool + +// If matched policies had no available windows whether to fall back +// to the default, or bail out with an error. +type FallbackDefault bool + +/* GetMaintenancePolicy gets the maintenance window policy based on the policy name we specify + * non-nil error returned if meeting one of below conditions: + * - the speficied maintenance policy doesn't exist. + * - error during unmarshal the policy data. + */ +func GetMaintenancePolicy(pool map[string]*[]byte, name string) (*MaintenanceWindowPolicy, error) { + if name == "" { + return nil, nil //nolint: nilnil //changing that now would break the API + } + + extName := name + ".json" + data, exist := pool[extName] + if !exist { + return nil, fmt.Errorf("%w: %s", ErrPolicyNotExists, name) + } + + policy, err := NewMaintenanceWindowPolicyFromJSON(*data) + if err != nil { + return nil, err + } + + return &policy, nil +} + +/* + * This function parse a JSON document from a byte array into a + * MaintenanceWindowPolicy structure, and returns it. If any errors + * are encountered, the error is returned, and the structured return data + * is undefined. + * + * Once a MaintenanceWindowPolicy is returned its Resolve method can be used to find + * A suitable maintenance window. + */ +func NewMaintenanceWindowPolicyFromJSON(raw []byte) (MaintenanceWindowPolicy, error) { + var ruleset MaintenanceWindowPolicy + + err := json.Unmarshal(raw, &ruleset) + if err != nil { + return ruleset, fmt.Errorf("%w: %w", ErrJSONUnmarshal, err) + } + return ruleset, nil +} + +/* + * Finds the next applicatable maintenance window for a given runtime on the plan. + * + * The algorithm can be parameterized using the following typed varargs: + * - TimeStamp: A time.Time, to specify the resolving's time instead of now + * - OngoingWindow: A boolean, if true then already started windows are returned + * if long enough. Defaults to false. + * - MinWindowSize: A time.Duration, when OngoingWindow is true, this holds the + * minimum size for the windows. Defaults to 1h. + * - FirstMatchOnly: A boolean indicating wether or not to stop at the first + * matching rule in the ruleset before proceeding to the defaults. + * Defaults to true. + * - FallbackDefault: A boolean indicating whether or not fall back to the default + * rules if no specific matching rules are found. Defaults to true. + * + * If a match is found then a ResolvedWindow pointer is returned with a nil error. + * Otherwise an error is returned and the ResolvedWindow pointer is expected to be + * nil. + */ +func (mwp *MaintenanceWindowPolicy) Resolve(runtime *Runtime, opts ...interface{}) (*ResolvedWindow, error) { + // first set up the internal logic parameters + // defaults here + options := resolveOptions{ + time: time.Now(), + ongoing: false, + minDuration: time.Hour, + firstMatchOnly: true, + fallbackDefault: true, + } + + // overrides from typed varargs + for idx, opt := range opts { + switch val := opt.(type) { + case TimeStamp: + options.time = time.Time(val) + case OngoingWindow: + options.ongoing = bool(val) + case MinWindowSize: + options.minDuration = time.Duration(val) + case FirstMatchOnly: + options.firstMatchOnly = bool(val) + case FallbackDefault: + options.fallbackDefault = bool(val) + default: + return nil, fmt.Errorf("%w at %d: %s/%+v", ErrUnknownOption, + idx, reflect.TypeOf(opt), opt) + } + } + + // first let's see whether any policies are having matching rules + matched := false + for _, policyrule := range mwp.Rules { + if matched = policyrule.Match.Match(runtime); !matched { + continue + } + // this policy is matching + + // we need to find the first window in the future + window := policyrule.Windows.LookupAvailable(&options) + if window != nil { + return window, nil + } + + // close but no cigar + if options.firstMatchOnly { + break + } + } + + // if we don't fall back to default if matches had no available + // windows then we error out + if matched && !options.fallbackDefault { + return nil, ErrNoWindowInPolicies + } + + // we do the default ruleset, if there are no matches + if rw := mwp.Default.NextWindow(&options); rw != nil { + return rw, nil + } + + return nil, ErrNoWindowFound +} + +type MaintenancePolicyRule struct { + Match MaintenancePolicyMatch `json:"match"` + Windows MaintenanceWindows `json:"windows"` +} +type MaintenanceWindows []MaintenanceWindow + +func (mws *MaintenanceWindows) LookupAvailable(opts *resolveOptions) *ResolvedWindow { + for _, mw := range *mws { + if window := mw.NextWindow(opts); window != nil { + return window + } + } + return nil +} + +type MaintenancePolicyMatch struct { + GlobalAccountID Regexp `json:"globalAccountID,omitempty"` //nolint:tagliatelle //changing that now would break the API + Plan Regexp `json:"plan,omitempty"` + Region Regexp `json:"region,omitempty"` + PlatformRegion Regexp `json:"platformRegion,omitempty"` +} + +func (mpm MaintenancePolicyMatch) String() string { + ret := "" +} + +func (mpm MaintenancePolicyMatch) Match(runtime *Runtime) bool { + // programmer is running with -fno-unroll-loops + for _, field := range []string{ + "GlobalAccountID", "Plan", + "Region", "PlatformRegion", + } { + rexp := reflect.Indirect(reflect.ValueOf(mpm)).FieldByName(field).Interface().(Regexp) //nolint:forcetypeassert //we know it's a Regexp + if !rexp.IsValid() { + continue + } + value := reflect.Indirect(reflect.ValueOf(runtime)).FieldByName(field).String() + if len(value) > 0 && rexp.MatchString(value) { + return true + } + } + return false +} + +type Regexp struct { + Str string + Regexp *regexp.Regexp +} + +func NewRegexp(pattern string) Regexp { + return Regexp{ + Str: pattern, + Regexp: regexp.MustCompile(pattern), + } +} + +func (r *Regexp) UnmarshalJSON(data []byte) error { + r.Str = string(bytes.Trim(data, `"`)) + if len(r.Str) == 0 { + r.Regexp = nil + return nil + } + var err error + r.Regexp, err = regexp.Compile(r.Str) + if err != nil { + return fmt.Errorf("%w: %w", ErrJSONUnmarshal, err) + } + return nil +} + +func (r Regexp) MatchString(s string) bool { + return r.Regexp.MatchString(s) +} + +func (r Regexp) IsValid() bool { + return r.Regexp != nil +} + +func (r Regexp) String() string { + return r.Str +} + +/* +If days is empty, then begin and end are ISO8601 strings with +exact times, otherwise if days is specified it's a time-only (with timezone). +*/ +type MaintenanceWindow struct { + Days []string `json:"days"` + Begin WindowTime `json:"begin"` + End WindowTime `json:"end"` +} + +// this has two main modes: whether we have days or not. +func (mw *MaintenanceWindow) NextWindow(opts *resolveOptions) *ResolvedWindow { + if len(mw.Days) == 0 { + // in this case begin and end are absolute units + if rw := windowWithin(opts, mw.Begin.T(), mw.End.T()); rw != nil { + return rw + } + } else { + // right here begin and end are simply times within the duration of a day + // logic is, we construct today's begin and end timestamps with the supplied + // time, and we keep on stepping it day by day until we hit one of the windows + begin := time.Date(opts.time.Year(), opts.time.Month(), opts.time.Day(), + mw.Begin.T().Hour(), mw.Begin.T().Minute(), mw.Begin.T().Second(), + 0, mw.Begin.T().Location()) + end := time.Date(opts.time.Year(), opts.time.Month(), opts.time.Day(), + mw.End.T().Hour(), mw.End.T().Minute(), mw.End.T().Second(), + 0, mw.End.T().Location()) + + // next day diff + incr := time24Hours + + // if it goes through midnight + if end.Before(begin) || end.Equal(begin) { + end = end.Add(incr) + } + + // now get the next suitable + // days are weekdays, and there's a total of 7 of them, so iterating ahead + // of that would be getting the next cycle, so we stop at a week's lookahead + for range 8 { + day3 := begin.Weekday().String()[0:3] + // if this day is not available, then next + if !slices.Contains(mw.Days, day3) { + begin = begin.Add(incr) + end = end.Add(incr) + continue + } + if rw := windowWithin(opts, begin, end); rw != nil { + return rw + } + begin = begin.Add(incr) + end = end.Add(incr) + } + } + return nil +} + +// type alias for (un)marshalling. +type WindowTime time.Time + +func (wt *WindowTime) UnmarshalJSON(data []byte) error { + trimmed := string(bytes.Trim(data, `"`)) + + // try the fullformat first + tParsed, err := time.Parse(time.RFC3339, trimmed) + if err == nil { + *wt = WindowTime(tParsed) + return nil + } + + // now try the time-only format + tParsed, err = time.Parse(timeOnlyFormat, trimmed) + if err == nil { + *wt = WindowTime(tParsed) + return nil + } + + return &json.UnsupportedValueError{ + Value: reflect.ValueOf(trimmed), + Str: fmt.Sprintf("Unable to parse value \"%s\" as ISO8601 or timeonly-with-tz", + trimmed), + } +} + +func (wt WindowTime) T() time.Time { + return time.Time(wt) +} + +// utility functions. +func windowWithin(opts *resolveOptions, begin time.Time, end time.Time) *ResolvedWindow { + if !opts.ongoing { + // simple, just verify whether the begin is in the future + if opts.time.Before(begin) { + return &ResolvedWindow{ + Begin: begin, + End: end, + } + } + } else { + // in this case we need to verify that the end window is in the future + // AND is at least minDuration ahead of us + if opts.time.Add(opts.minDuration).Before(end) { + return &ResolvedWindow{ + Begin: begin, + End: end, + } + } + } + return nil +} diff --git a/maintenancewindows/resolver/maintwindow_test.go b/maintenancewindows/resolver/maintwindow_test.go new file mode 100644 index 0000000000..63ff11dee3 --- /dev/null +++ b/maintenancewindows/resolver/maintwindow_test.go @@ -0,0 +1,325 @@ +package resolver_test + +import ( + "fmt" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/kyma-project/lifecycle-manager/maintenancewindows/resolver" +) + +const testfile = "testdata/ruleset-1.json" + +type testData struct { + runtime resolver.Runtime + expected bool +} + +func createRuntime(gaid string, plan string, region string, + platformregion string, +) resolver.Runtime { + return resolver.Runtime{ + GlobalAccountID: gaid, + Plan: plan, + Region: region, + PlatformRegion: platformregion, + } +} + +func resWin(begin string, end string) resolver.ResolvedWindow { + bTime, err := time.Parse(time.RFC3339, begin) + if err != nil { + panic(err.Error()) + } + eTime, err := time.Parse(time.RFC3339, end) + if err != nil { + panic(err.Error()) + } + return resolver.ResolvedWindow{ + Begin: bTime, + End: eTime, + } +} + +func at(timestamp string) resolver.TimeStamp { + t, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + panic(err.Error()) + } + return resolver.TimeStamp(t) +} + +type testCase struct { + name string + runtime resolver.Runtime + options []interface{} + errors bool + expected resolver.ResolvedWindow +} + +func (tc testCase) Message() string { + opts := []string{} + for idx, opt := range tc.options { + switch val := opt.(type) { + case resolver.TimeStamp: + t := time.Time(val) + opts = append(opts, fmt.Sprintf("At:%s(%s)", t.String(), t.Weekday())) + case resolver.OngoingWindow: + opts = append(opts, fmt.Sprintf("Ongoing:%v", bool(val))) + case resolver.MinWindowSize: + opts = append(opts, fmt.Sprintf("MinDuration:%v", time.Duration(val))) + case resolver.FirstMatchOnly: + opts = append(opts, fmt.Sprintf("FirstMatchOnly:%v", bool(val))) + case resolver.FallbackDefault: + opts = append(opts, fmt.Sprintf("FallbackDefault:%v", bool(val))) + // forward something we can test interface error handling + case string: + opts = append(opts, val) + default: + panic(fmt.Sprintf("Unknown option at %d: %s/%+v", + idx, reflect.TypeOf(opt), opt)) + } + } + return fmt.Sprintf("testCase:\n - Name(%s)\n - Opts(%s)\n - Runtime(GAID:%s Plan:%s Region:%s PlatformRegion:%s)", + tc.name, strings.Join(opts, " "), + tc.runtime.GlobalAccountID, tc.runtime.Plan, tc.runtime.Region, + tc.runtime.PlatformRegion) +} + +type MaintWindowSuite struct { + suite.Suite + plan resolver.MaintenanceWindowPolicy + testCases []testCase +} + +func (suite *MaintWindowSuite) SetupSuite() { + // load the testing ruleset + rawdata, err := os.ReadFile(testfile) + suite.Require().NoErrorf(err, "Unable to read testdata from %s", testfile) + suite.Require().NotNil(rawdata) + + suite.plan, err = resolver.NewMaintenanceWindowPolicyFromJSON(rawdata) + suite.Require().NoError(err) + + // specify the testcases + suite.testCases = []testCase{ + { + name: "freetrials next", + runtime: createRuntime("", "trial", "", ""), + options: []interface{}{at("2024-10-03T05:05:00Z")}, + errors: false, + expected: resWin("2024-10-04T01:00:00Z", "2024-10-05T01:00:00Z"), + }, + { + name: "ongoing", + runtime: createRuntime("", "", "uksouth-vikings", ""), + options: []interface{}{ + at("2024-10-10T22:05:00Z"), + resolver.OngoingWindow(true), + }, + errors: false, + expected: resWin("2024-10-10T20:00:00Z", "2024-10-11T00:00:00Z"), + }, + { + name: "ongoing+minsize", + runtime: createRuntime("", "", "uksouth-vikings", ""), + options: []interface{}{ + at("2024-10-10T22:05:00Z"), + resolver.OngoingWindow(true), + resolver.MinWindowSize(5 * time.Hour), + }, + errors: false, + expected: resWin("2024-12-08T20:00:00Z", "2024-12-09T00:00:00Z"), + }, + { + name: "not just first match", + runtime: createRuntime("", "", "uksouth-vikings", ""), + options: []interface{}{ + at("2024-12-10T22:05:00Z"), + resolver.FirstMatchOnly(false), + }, + errors: false, + expected: resWin("2024-12-18T20:00:00Z", "2024-12-19T00:00:00Z"), + }, + { + name: "first match fail -> default", + runtime: createRuntime("", "", "uksouth-vikings", ""), + options: []interface{}{ + at("2024-12-10T22:05:00Z"), + resolver.FirstMatchOnly(true), + }, + errors: false, + expected: resWin("2024-12-14T00:00:00Z", "2024-12-15T00:00:00Z"), + }, + { + name: "first match fail -> nodefault", + runtime: createRuntime("", "", "uksouth-vikings", ""), + options: []interface{}{ + at("2024-12-10T22:05:00Z"), + resolver.FirstMatchOnly(true), resolver.FallbackDefault(false), + }, + errors: true, + expected: resWin("2024-12-14T00:00:00Z", "2024-12-15T00:00:00Z"), + }, + { + name: "wrong arg", + runtime: createRuntime("", "", "uksouth-vikings", ""), + options: []interface{}{ + at("2024-12-10T22:05:00Z"), + resolver.FirstMatchOnly(true), resolver.FallbackDefault(false), + "lol", + }, + errors: true, + expected: resWin("2042-12-14T00:00:00Z", "2024-12-15T00:00:00Z"), + }, + } +} + +func (suite *MaintWindowSuite) Test_Match_Plans() { + testdata := []testData{ + { + runtime: createRuntime("", "free", "", ""), + expected: true, + }, + { + runtime: createRuntime("", "trial", "", ""), + expected: true, + }, + { + runtime: createRuntime("", "azure_lite", "", ""), + expected: false, + }, + } + + matcher := suite.plan.Rules[0].Match + for _, subject := range testdata { + suite.Require().Equal(subject.expected, matcher.Match(&subject.runtime)) + } +} + +func (suite *MaintWindowSuite) Test_Match_Plan() { + testdata := []testData{ + { + runtime: createRuntime("", "free", "", ""), + expected: true, + }, + { + runtime: createRuntime("", "trial", "", ""), + expected: true, + }, + { + runtime: createRuntime("", "azure_lite", "", ""), + expected: false, + }, + } + + matcher := suite.plan.Rules[0].Match + for _, subject := range testdata { + suite.Require().Equal(subject.expected, matcher.Match(&subject.runtime)) + } +} + +func (suite *MaintWindowSuite) Test_Match_Region() { + testdata := []testData{ + { + runtime: createRuntime("", "", "eu-balkan-1", ""), + expected: true, + }, + { + runtime: createRuntime("", "", "uksouth-teaparty", ""), + expected: true, + }, + { + runtime: createRuntime("", "", "us-cottoneyejoe", ""), + expected: false, + }, + } + + matcher := suite.plan.Rules[1].Match + for _, subject := range testdata { + suite.Require().Equal(subject.expected, matcher.Match(&subject.runtime)) + } +} + +func (suite *MaintWindowSuite) Test_Match_GAID() { + testdata := []testData{ + { + runtime: createRuntime("sup-er-ga-case", "", "", ""), + expected: true, + }, + { + runtime: createRuntime("not-matching", "", "", ""), + expected: false, + }, + } + + matcher := suite.plan.Rules[2].Match + for _, subject := range testdata { + suite.Require().Equal(subject.expected, matcher.Match(&subject.runtime)) + } +} + +func (suite *MaintWindowSuite) Test_Match_PlatformRegion() { + testdata := []testData{ + { + runtime: createRuntime("", "", "uksouth-teaparty", "super-mario-bros"), + expected: true, + }, + { + runtime: createRuntime("", "", "us-cottoneyejoe", "luigi"), + expected: false, + }, + } + + matcher := suite.plan.Rules[2].Match + for _, subject := range testdata { + suite.Require().Equal(subject.expected, matcher.Match(&subject.runtime)) + } +} + +func (suite *MaintWindowSuite) Test_Match_TestCases() { + /* + runtime resolver.Runtime + errors bool + at time.Time + expected resolver.ResolvedWindow + */ + for _, tcase := range suite.testCases { + result, err := suite.plan.Resolve(&tcase.runtime, tcase.options...) + if tcase.errors { + suite.Require().Errorf(err, "test:\n%s\nresult:\n%v\n", tcase.Message(), result) + suite.Require().Nil(result, tcase.Message()) + } else { + suite.Require().NoError(err, tcase.Message()) + suite.Require().NotNil(result, tcase.Message()) + } + if result != nil && err == nil { + suite.Require().Equal(tcase.expected.String(), result.String(), tcase.Message()) + } + } +} + +func Test_RunMaintWindowSuite(t *testing.T) { + suite.Run(t, new(MaintWindowSuite)) +} + +func Test_MPMString(t *testing.T) { + gaid := "blah1" + plan := "blah2" + reg := "blah3" + preg := "blah4" + data := resolver.MaintenancePolicyMatch{ + GlobalAccountID: resolver.NewRegexp(gaid), + Plan: resolver.NewRegexp(plan), + Region: resolver.NewRegexp(reg), + PlatformRegion: resolver.NewRegexp(preg), + } + expected := fmt.Sprintf("", gaid, plan, reg, preg) + require.Equal(t, expected, data.String()) +} diff --git a/maintenancewindows/resolver/resolver.go b/maintenancewindows/resolver/resolver.go new file mode 100644 index 0000000000..bda4b6b0c9 --- /dev/null +++ b/maintenancewindows/resolver/resolver.go @@ -0,0 +1,64 @@ +package resolver + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + PolicyPathENV = "MAINTENANCE_POLICY_PATH" +) + +var ( + ErrNoPolicyPathEnvVar = errors.New("no environment variable set for the maintenance policy path") + ErrReadingDirectory = errors.New("error while reading the directory") +) + +// Runtime is the data type which captures the needed runtime specific attributes to perform orchestrations on a given runtime. +type Runtime struct { + InstanceID string + RuntimeID string + GlobalAccountID string + SubAccountID string + ShootName string + Plan string + Region string + PlatformRegion string + MaintenanceWindowBegin time.Time + MaintenanceWindowEnd time.Time + MaintenanceDays []string +} + +// GetMaintenancePolicyPool extracts and returns the maintenance policies we have under the policy directory. +func GetMaintenancePolicyPool() (map[string]*[]byte, error) { + pool := map[string]*[]byte{} + + path := os.Getenv(PolicyPathENV) + if path == "" { + return nil, ErrNoPolicyPathEnvVar + } + + entries, err := os.ReadDir(path) + if err != nil { + return nil, fmt.Errorf("%w %s: %w", ErrReadingDirectory, path, err) + } + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || filepath.Ext(name) != ".json" { + continue + } + + data, err := os.ReadFile(filepath.Join(path, name)) + if err != nil { + return nil, fmt.Errorf("error while reading the file %s: %w", name, err) + } + + pool[name] = &data + } + + return pool, nil +} diff --git a/maintenancewindows/resolver/resolver_test.go b/maintenancewindows/resolver/resolver_test.go new file mode 100644 index 0000000000..994cfebfd5 --- /dev/null +++ b/maintenancewindows/resolver/resolver_test.go @@ -0,0 +1,26 @@ +package resolver_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/lifecycle-manager/maintenancewindows/resolver" +) + +func TestGetMaintenancePolicyPool(t *testing.T) { + t.Setenv(resolver.PolicyPathENV, "./testdata") + + pool, err := resolver.GetMaintenancePolicyPool() + require.NoError(t, err) + + assert.Len(t, pool, 2) + assert.Contains(t, pool, "ruleset-1.json") + assert.Contains(t, pool, "ruleset-2.json") + + data1 := pool["ruleset-1.json"] + data2 := pool["ruleset-2.json"] + assert.NotNil(t, data1) + assert.NotNil(t, data2) +} diff --git a/maintenancewindows/resolver/testdata/ruleset-1.json b/maintenancewindows/resolver/testdata/ruleset-1.json new file mode 100644 index 0000000000..2cfc14c571 --- /dev/null +++ b/maintenancewindows/resolver/testdata/ruleset-1.json @@ -0,0 +1,103 @@ +{ + "rules": [ + { + "match": { + "plan": "trial|free" + }, + "windows": [ + { + "days": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ], + "begin": "01:00:00Z", + "end": "01:00:00Z" + } + ] + }, + { + "match": { + "region": "europe|eu-|uksouth" + }, + "windows": [ + { + "begin": "2024-10-10T20:00:00Z", + "end": "2024-10-11T00:00:00Z" + }, + { + "begin": "2024-12-08T20:00:00Z", + "end": "2024-12-09T00:00:00Z" + } + ] + }, + { + "match": { + "globalAccountId": "sup-er-ga-case", + "platformRegion": "super-mario" + }, + "windows": [ + { + "begin": "2024-10-10T20:00:00Z", + "end": "2024-10-11T00:00:00Z" + }, + { + "begin": "2024-12-08T20:00:00Z", + "end": "2024-12-09T00:00:00Z" + } + ] + }, + { + "match": { + "region": "asia|japan|australia|ap-" + }, + "windows": [ + { + "begin": "2024-10-15T13:00:00Z", + "end": "2024-10-15T17:00:00Z" + }, + { + "begin": "2024-12-13T13:00:00Z", + "end": "2024-12-13T17:00:00Z" + } + ] + }, + { + "match": { + "region": "europe|eu-|uksouth" + }, + "windows": [ + { + "begin": "2024-12-18T20:00:00Z", + "end": "2024-12-19T00:00:00Z" + } + ] + }, + { + "match": { + "region": "centralus|eastus|westus|northamerica|southamerica|us-|ca-|sa-" + }, + "windows": [ + { + "begin": "2024-10-20T04:00:00Z", + "end": "2024-10-20T08:00:00Z" + }, + { + "begin": "2024-12-20T04:00:00Z", + "end": "2024-12-13T08:00:00Z" + } + ] + } + ], + "default": { + "days": [ + "Sat" + ], + "timeBegin": "20:00:00Z", + "timeEnd": "00:00:00Z" + } +} diff --git a/maintenancewindows/resolver/testdata/ruleset-2.json b/maintenancewindows/resolver/testdata/ruleset-2.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/maintenancewindows/resolver/testdata/ruleset-2.json @@ -0,0 +1 @@ +{} diff --git a/maintenancewindows/resolver/testdata/ruleset-3.yaml b/maintenancewindows/resolver/testdata/ruleset-3.yaml new file mode 100644 index 0000000000..a7850a0422 --- /dev/null +++ b/maintenancewindows/resolver/testdata/ruleset-3.yaml @@ -0,0 +1,5 @@ +rules: +- match: + plan: "trial" +- match: + plan: "free" From 014a8c12716926e7618e3e5e093b28cd39f3f01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20Schw=C3=A4gerl?= Date: Tue, 7 Jan 2025 09:59:36 +0100 Subject: [PATCH 2/4] chore: Add VSCode launch config for tests (#2160) --- .vscode/launch.json | 71 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index c0e978007b..b5c93ff405 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,6 +17,75 @@ "ENABLE_WEBHOOKS": "false" }, "preLaunchTask": "Install CRDs" - } + }, + { + "name": "Launch KLM Integration Tests - Package of current file", + "type": "go", + "request": "launch", + "mode": "test", + "program": "${fileDirname}", + "args": ["-test.v", "-ginkgo.flake-attempts=10"], + "env": { + // make sure you added the following to your VSCODE settings.json + "KUBEBUILDER_ASSETS": "${config:go.testEnvVars.KUBEBUILDER_ASSETS}" + }, + }, + { + "name": "Launch KLM E2E Test", + "type": "go", + "request": "launch", + "mode": "test", + "program": "${workspaceFolder}/tests/e2e", + "args": ["-test.timeout", "20m", "-ginkgo.v", "-ginkgo.focus", "${input:e2eTestTargetName}"], + "env": { + "KCP_KUBECONFIG": "${env:HOME}/.k3d/kcp-local.yaml", + "SKR_KUBECONFIG": "${env:HOME}/.k3d/skr-local.yaml", + } + }, + ], + "inputs": [ + { + // not all of the options work OOTB, see deploy-lifecycle-manager-e2e action.yaml for specific patches + "id": "e2eTestTargetName", + "type": "pickString", + "description": "E2E test target name", + "options": [ + "KCP Kyma CR Deprovision With Foreground Propagation After SKR Cluster Removal", + "KCP Kyma CR Deprovision With Background Propagation After SKR Cluster Removal", + "Manage Module Metrics", + "Mandatory Module Metrics", + "Mandatory Module With Old Naming Pattern Metrics", + "Enqueue Event from Watcher", + "Module Status Decoupling With StatefulSet", + "Module Status Decoupling With Deployment", + "Module Without Default CR", + "Module Keep Consistent After Deploy", + "Mandatory Module Installation and Deletion", + "Mandatory Module With Old Naming Pattern Installation and Deletion", + "Non Blocking Kyma Module Deletion", + "Manifest Skip Reconciliation Label", + "Kyma Module Upgrade Under Deletion", + "Kyma Module with ModuleReleaseMeta Upgrade Under Deletion", + "Unmanaging Kyma Module", + "Purge Controller", + "Purge Metrics", + "Module Upgrade By Channel Switch", + "Module Upgrade By New Version", + "Module with ModuleReleaseMeta Upgrade By New Version", + "Module Install By Version", + "CA Certificate Rotation", + "Istio Gateway Secret Rotation", + "Self Signed Certificate Rotation", + "Misconfigured Kyma Secret", + "RBAC Privileges", + "OCM Format Module Template", + "ModuleReleaseMeta With Obsolete ModuleTemplate", + "ModuleReleaseMeta Watch Trigger", + "ModuleReleaseMeta Sync", + "KCP Kyma Module status on SKR connection lost", + "ModuleReleaseMeta Not Allowed Installation", + "Labelling SKR resources" + ] + }, ] } From 4a8e02acd40ec086e8ef25fbd3f64cf1718c617c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jan 2025 10:03:33 +0000 Subject: [PATCH 3/4] chore(dependabot): bump golang.org/x/time from 0.8.0 to 0.9.0 (#2159) Bumps [golang.org/x/time](https://github.com/golang/time) from 0.8.0 to 0.9.0. - [Commits](https://github.com/golang/time/compare/v0.8.0...v0.9.0) --- updated-dependencies: - dependency-name: golang.org/x/time dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nesma Badr --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9fb477c089..38451b2d2f 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/stretchr/testify v1.10.0 go.uber.org/zap v1.27.0 golang.org/x/sync v0.10.0 - golang.org/x/time v0.8.0 + golang.org/x/time v0.9.0 k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 ocm.software/ocm v0.19.0 sigs.k8s.io/controller-runtime v0.19.3 diff --git a/go.sum b/go.sum index 7a79a0f62a..3dc10d9c39 100644 --- a/go.sum +++ b/go.sum @@ -1187,8 +1187,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= From ca952cb8ec4793620e773d2000287b7952ed2604 Mon Sep 17 00:00:00 2001 From: Nesma Badr Date: Tue, 7 Jan 2025 11:34:46 +0100 Subject: [PATCH 4/4] chore(deps): Bump golang.org/x/net to v0.33.0 (#2161) Bump golang.org/x/net to v0.33.0 --- api/go.mod | 6 +++--- api/go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/go.mod b/api/go.mod index 6c858a0cb6..190a084f00 100644 --- a/api/go.mod +++ b/api/go.mod @@ -34,10 +34,10 @@ require ( github.com/x448/float16 v0.8.4 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect - golang.org/x/net v0.30.0 // indirect + golang.org/x/net v0.33.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/term v0.25.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/term v0.27.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.7.0 // indirect google.golang.org/protobuf v1.35.1 // indirect diff --git a/api/go.sum b/api/go.sum index 29a0d5ec65..8825257bf0 100644 --- a/api/go.sum +++ b/api/go.sum @@ -95,8 +95,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -105,10 +105,10 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= -golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=