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"