diff --git a/integrationservertest/config.go b/integrationservertest/config.go new file mode 100644 index 0000000000..47e2f700bc --- /dev/null +++ b/integrationservertest/config.go @@ -0,0 +1,319 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integrationservertest + +import ( + "errors" + "fmt" + "io" + "os" + "regexp" + "strconv" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/elastic/apm-server/integrationservertest/internal/ech" +) + +const ( + upgradeConfigFilename = "upgrade-config.yaml" + dockerImageOverrideFilename = "docker-image-override.yaml" +) + +type upgradeTestConfig struct { + DataStreamLifecycle map[string]string + LazyRolloverExceptions []lazyRolloverException +} + +// ExpectedLifecycle returns the lifecycle management that is expected of the provided version. +func (cfg upgradeTestConfig) ExpectedLifecycle(version ech.Version) string { + lifecycle, ok := cfg.DataStreamLifecycle[version.MajorMinor()] + if !ok { + return managedByILM + } + if strings.EqualFold(lifecycle, "DSL") { + return managedByDSL + } + return managedByILM +} + +// LazyRollover checks if the upgrade path is expected to have lazy rollover. +func (cfg upgradeTestConfig) LazyRollover(from, to ech.Version) bool { + for _, e := range cfg.LazyRolloverExceptions { + if e.matchVersions(from, to) { + return false + } + } + return true +} + +func parseConfigFile(filename string) (upgradeTestConfig, error) { + f, err := os.Open(filename) + if err != nil { + return upgradeTestConfig{}, fmt.Errorf("failed to open %s: %w", filename, err) + } + + defer f.Close() + return parseConfig(f) +} + +func parseConfig(reader io.Reader) (upgradeTestConfig, error) { + type lazyRolloverExceptionYAML struct { + From string `yaml:"from"` + To string `yaml:"to"` + } + + type upgradeTestConfigYAML struct { + DataStreamLifecycle map[string]string `yaml:"data-stream-lifecycle"` + LazyRolloverExceptions []lazyRolloverExceptionYAML `yaml:"lazy-rollover-exceptions"` + } + + b, err := io.ReadAll(reader) + if err != nil { + return upgradeTestConfig{}, fmt.Errorf("failed to read config: %w", err) + } + + configYAML := upgradeTestConfigYAML{} + if err := yaml.Unmarshal(b, &configYAML); err != nil { + return upgradeTestConfig{}, fmt.Errorf("failed to unmarshal upgrade test config: %w", err) + } + + config := upgradeTestConfig{ + DataStreamLifecycle: configYAML.DataStreamLifecycle, + } + + for _, e := range configYAML.LazyRolloverExceptions { + lre, err := parseLazyRolloverException(e.From, e.To) + if err != nil { + return upgradeTestConfig{}, fmt.Errorf("failed to parse lazy-rollover exception: %w", err) + } + config.LazyRolloverExceptions = append(config.LazyRolloverExceptions, lre) + } + + return config, nil +} + +type lazyRolloverException struct { + From lreVersion + To lreVersion +} + +func (e lazyRolloverException) matchVersions(from, to ech.Version) bool { + // Either version not in range. + if !e.From.matchVersion(from) || !e.To.matchVersion(to) { + return false + } + // If both pattern have minor x, check if both version minors are the same. + if e.From.isSingularWithMinorX() && e.To.isSingularWithMinorX() { + return from.Minor == to.Minor + } + return true +} + +type lreVersion struct { + Singular *wildcardVersion // Nil if range. + Range *lreVersionRange // Nil if singular version. +} + +func (r lreVersion) matchVersion(version ech.Version) bool { + // Range of versions. + if r.Range != nil { + return r.Range.inRange(version) + } + + // Singular version only. + if r.Singular.Major != version.Major { + return false + } + // Both wildcards, return true since major is equal. + if r.Singular.Minor.isWildcard() && r.Singular.Patch.isWildcard() { + return true + } + // Only patch is wildcard, check minor. + if r.Singular.Patch.isWildcard() { + return *r.Singular.Minor.Num == version.Minor + } + // Only minor is wildcard, check patch. + return *r.Singular.Patch.Num == version.Patch +} + +func (r lreVersion) isSingularWithMinorX() bool { + return r.Singular != nil && r.Singular.Minor.isX() +} + +type lreVersionRange struct { + Start ech.Version + InclusiveStart bool + End ech.Version + InclusiveEnd bool +} + +func (r lreVersionRange) inRange(version ech.Version) bool { + cmpStart := r.Start.Compare(version) < 0 + if r.InclusiveStart { + cmpStart = r.Start.Compare(version) <= 0 + } + cmpEnd := r.End.Compare(version) > 0 + if r.InclusiveEnd { + cmpEnd = r.End.Compare(version) >= 0 + } + return cmpStart && cmpEnd +} + +type wildcardVersion struct { + Major uint64 + Minor numberOrWildcard + Patch numberOrWildcard +} + +type numberOrWildcard struct { + Num *uint64 // Not nil if Str is a number. + Str string +} + +func (n numberOrWildcard) isWildcard() bool { + return n.Num == nil +} + +func (n numberOrWildcard) isX() bool { + return n.Str == "x" +} + +func (n numberOrWildcard) isStar() bool { + return n.Str == "*" +} + +func parseNumberOrWildcard(s string) (numberOrWildcard, error) { + if s == "*" || s == "x" { + return numberOrWildcard{Str: s}, nil + } + + num, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return numberOrWildcard{}, err + } + return numberOrWildcard{Num: &num, Str: s}, nil +} + +func parseLazyRolloverException(fromStr, toStr string) (lazyRolloverException, error) { + from, err := parseLazyRolloverExceptionVersionRange(fromStr) + if err != nil { + return lazyRolloverException{}, fmt.Errorf( + "failed to parse from version '%s': %w", fromStr, err) + } + to, err := parseLazyRolloverExceptionVersionRange(toStr) + if err != nil { + return lazyRolloverException{}, fmt.Errorf( + "failed to parse to version '%s': %w", toStr, err) + } + // Check that if one version have x wildcard, the other should also have it. + if (from.isSingularWithMinorX() && !to.isSingularWithMinorX()) || + (!from.isSingularWithMinorX() && to.isSingularWithMinorX()) { + return lazyRolloverException{}, fmt.Errorf( + "both versions ('%s', '%s') should have special wildcard or none at all", fromStr, toStr) + } + + return lazyRolloverException{ + From: from, + To: to, + }, nil +} + +var ( + lazyRolloverVersionRg = regexp.MustCompile(`^(\d+).(\d+|\*|x).(\d+|\*)$`) + lazyRolloverVersionRangeRg = regexp.MustCompile(`^([\[(])\s*(\d+.\d+.\d+)\s*-\s*(\d+.\d+.\d+)\s*([])])$`) +) + +func parseLazyRolloverExceptionVersionRange(s string) (lreVersion, error) { + // Range of versions. + matches := lazyRolloverVersionRangeRg.FindStringSubmatch(s) + if len(matches) > 0 { + inclusiveStart := matches[1] == "[" + inclusiveEnd := matches[4] == "]" + start, err := ech.NewVersionFromString(matches[2]) + if err != nil { + return lreVersion{}, fmt.Errorf("failed to parse '%s' start: %w", s, err) + } + end, err := ech.NewVersionFromString(matches[3]) + if err != nil { + return lreVersion{}, fmt.Errorf("failed to parse '%s' end: %w", s, err) + } + return lreVersion{ + Range: &lreVersionRange{ + Start: start, + InclusiveStart: inclusiveStart, + End: end, + InclusiveEnd: inclusiveEnd, + }, + }, nil + } + + // Singular version only. + matches = lazyRolloverVersionRg.FindStringSubmatch(s) + if len(matches) > 0 { + major, err := strconv.ParseUint(matches[1], 10, 64) + if err != nil { + return lreVersion{}, fmt.Errorf("failed to parse '%s' major: %w", s, err) + } + minor, err := parseNumberOrWildcard(matches[2]) + if err != nil { + return lreVersion{}, fmt.Errorf("failed to parse '%s' minor: %w", s, err) + } + patch, err := parseNumberOrWildcard(matches[3]) + if err != nil { + return lreVersion{}, fmt.Errorf("failed to parse '%s' patch: %w", s, err) + } + return lreVersion{ + Singular: &wildcardVersion{ + Major: major, + Minor: minor, + Patch: patch, + }, + }, nil + } + + return lreVersion{}, fmt.Errorf("invalid version pattern '%s'", s) +} + +func parseDockerImageOverride(filename string) (map[ech.Version]*dockerImageOverrideConfig, error) { + b, err := os.ReadFile(filename) + if err != nil { + // File does not exist, fallback to no overrides. + if errors.Is(err, os.ErrNotExist) { + return map[ech.Version]*dockerImageOverrideConfig{}, nil + } + return nil, fmt.Errorf("failed to read %s: %w", filename, err) + } + + config := map[string]*dockerImageOverrideConfig{} + if err = yaml.Unmarshal(b, &config); err != nil { + return nil, fmt.Errorf("failed to unmarshal docker image override config: %w", err) + } + + result := map[ech.Version]*dockerImageOverrideConfig{} + for k, v := range config { + version, err := ech.NewVersionFromString(k) + if err != nil { + return nil, fmt.Errorf("invalid version in docker image override config: %w", err) + } + result[version] = v + } + + return result, nil +} diff --git a/integrationservertest/config_test.go b/integrationservertest/config_test.go new file mode 100644 index 0000000000..4a45607078 --- /dev/null +++ b/integrationservertest/config_test.go @@ -0,0 +1,162 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integrationservertest + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/elastic/apm-server/integrationservertest/internal/ech" +) + +// TestInternal_ConfigLazyRollover will not be run in CI, make sure to run this test if +// you make any changes to the config parser. +func TestInternal_ConfigLazyRollover(t *testing.T) { + const testConfig = ` +lazy-rollover-exceptions: + # (1) No lazy rollover from all versions 1.* to all versions 2.*. + - from: "1.*.*" + to: "2.*.*" + # (2) No lazy rollover from versions 3.0.* to versions between 3.1.0 and 3.1.5. + - from: "3.0.*" + to: "[3.1.0 - 3.1.5]" + # (3) No lazy rollover from versions 4.2.* >= 4.2.11 to versions 4.2.* >= 4.2.11. + - from: "[4.2.11 - 4.3.0)" + to: "[4.2.11 - 4.3.0)" + # (4) No lazy rollover from versions 5.* to versions 5.* iff same minor. + - from: "5.x.*" + to: "5.x.*" + # (5) No lazy rollover from versions 6.* between 6.2.4 and 6.4.8 to versions 6.* after 6.3.12. + - from: "[6.2.4 - 6.4.8]" + to: "[6.3.12 - 7.0.0)" + # (6) No lazy rollover from version 7.1.1 to version 7.1.2. + - from: "7.1.1" + to: "7.1.2" +` + + cfg, err := parseConfig(strings.NewReader(testConfig)) + require.NoError(t, err) + + type args struct { + from string + to string + } + tests := []struct { + args args + want bool + }{ + // Case 1 + { + args: args{ + from: "1.2.3", + to: "2.4.6", + }, + want: false, + }, + // Case 2 + { + args: args{ + from: "3.0.15", + to: "3.1.2", + }, + want: false, + }, + { + args: args{ + from: "3.0.6", + to: "3.1.12", + }, + want: true, + }, + // Case 3 + { + args: args{ + from: "4.2.12", + to: "4.2.27", + }, + want: false, + }, + { + args: args{ + from: "4.2.11", + to: "4.3.0", + }, + want: true, + }, + // Case 4 + { + args: args{ + from: "5.1.2", + to: "5.1.6", + }, + want: false, + }, + { + args: args{ + from: "5.1.2", + to: "5.8.3", + }, + want: true, + }, + // Case 5 + { + args: args{ + from: "6.2.4", + to: "6.3.12", + }, + want: false, + }, + { + args: args{ + from: "6.4.8", + to: "6.9.9", + }, + want: false, + }, + // Case 6 + { + args: args{ + from: "7.1.1", + to: "7.1.2", + }, + want: false, + }, + { + args: args{ + from: "7.1.1", + to: "7.2.3", + }, + want: true, + }, + } + for _, tt := range tests { + name := fmt.Sprintf("%s to %s", tt.args.from, tt.args.to) + from, err := ech.NewVersionFromString(tt.args.from) + require.NoError(t, err) + to, err := ech.NewVersionFromString(tt.args.to) + require.NoError(t, err) + t.Run(name, func(t *testing.T) { + if got := cfg.LazyRollover(from, to); got != tt.want { + t.Errorf("LazyRollover() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/integrationservertest/standalone_test.go b/integrationservertest/standalone_test.go index 967573a35e..9e3e50dc2e 100644 --- a/integrationservertest/standalone_test.go +++ b/integrationservertest/standalone_test.go @@ -38,7 +38,7 @@ func TestStandaloneManaged_7_17_to_8_x_to_9_x_Snapshot(t *testing.T) { return } - config, err := parseConfig(upgradeConfigFilename) + config, err := parseConfigFile(upgradeConfigFilename) if err != nil { t.Fatal(err) } @@ -82,7 +82,7 @@ func expectationsFor9x( config upgradeTestConfig, ) map[string]asserts.DataStreamExpectation { expect9 := maps.Clone(expect8) - if config.HasLazyRollover(version8, version9) { + if config.LazyRollover(version8, version9) { for k, v := range expect9 { expect9[k] = asserts.DataStreamExpectation{ PreferIlm: v.PreferIlm, diff --git a/integrationservertest/upgrade-config.yaml b/integrationservertest/upgrade-config.yaml index aad5e2b4b5..61b085986c 100644 --- a/integrationservertest/upgrade-config.yaml +++ b/integrationservertest/upgrade-config.yaml @@ -1,19 +1,78 @@ -# Define the expected data stream lifecycle of the minor version. +# The expected data stream lifecycle of the minor version. # If not defined, defaults to ILM. data-stream-lifecycle: 8.15: DSL 8.16: DSL -# Define versions that have lazy rollover, i.e. when upgrading from some older -# version to the specified minor version, there will be lazy rollover. -# Exceptions are specified as a list, i.e. no lazy rollover if upgrade is from -# that minor version. -lazy-rollover-with-exceptions: - 8.16: - 8.17: - 8.18: - - "8.17" - 8.19: - 9.0: - 9.1: - 9.2: \ No newline at end of file +# Lazy rollover is by default expected for every single upgrade (be it between +# majors, minors or patches). This configuration lists down the exceptions to +# this expectation, in the form of from-version and to-version. During the +# upgrade tests, when the upgrade e.g. 8.19.4 -> 9.0.1, matches with any of the +# defined version pairs, there will be no lazy rollover expected in that upgrade. +# +# +# There are 2 ways of defining the exception version: +# +# +# 1. Using singular version with wildcards: +# - Match specific patch version : .. +# - Match any versions in minor : ..* +# - Match any versions in major : .*.* +# - Match any versions in major (special rule) : .x.* +# +# Note that the minor position is given a special wildcard character (x). +# This is because of a special rule when the pattern is used in both from and +# to. For example, the following uses x in both from and to, and is used to +# indicate that there is no lazy rollover from any 9.* patch to any other +# 9.* patch iff the minor is the same. +# ``` +# - from: "9.x.*" +# to: "9.x.*" +# ``` +# +# Both major and patch positions are not given the special wildcard (x) because +# it is unlikely to be useful in our use cases. The special wildcard (x) must +# either appear in both from and to versions, or none at all. The following +# example will be considered invalid. +# ``` +# - from: "9.x.*" +# to: "9.1.2" +# ``` +# +# +# 2. Using ranges: +# - Square brackets for inclusive e.g. [ - ] +# - Round brackets for exclusive e.g. ( - ) +# +# When using ranges, the versions defined in the range cannot contain any +# wildcards (*, x) and must be a full valid version with major, minor, patch. +# The brackets can be mixed to have inclusive start, exclusive end i.e. [A-B) +# or vice versa i.e. (A-B]. Here is an example: +# ``` +# - from: "(1.2.2 - 4.5.6]" +# to: "[2.0.0 - 3.0.0)" +# ``` +# +# Based on the example above, if the upgrade test from-version is in range +# e.g. 3.4.5 and to-version is also in range e.g. 2.6.7, the upgrade will be +# expected to not have lazy rollover. +lazy-rollover-exceptions: + # No lazy rollover from any 8.17 patch between [8.17.0, 8.17.7] to any other 8.17 patch between. + # This means that upgrading from e.g. 8.17.2 to 8.17.8 is expected to have rollover. + - from: "[8.17.0 - 8.17.7]" + to: "[8.17.0 - 8.17.7]" + # No lazy rollover from any 8.17 patch after 8.17.8 to any other 8.17 patch after 8.17.8. + - from: "[8.17.8 - 8.18.0)" + to: "[8.17.8 - 8.18.0)" + # No lazy rollover from any 8.18 patch to any other 8.18 patch. + - from: "8.18.*" + to: "8.18.*" + # No lazy rollover from any 8.19 patch to any other 8.19 patch. + - from: "8.19.*" + to: "8.19.*" + # No lazy rollover from any 9.* patch to any other 9.* patch iff minor is the same. + - from: "9.x.*" + to: "9.x.*" + # No lazy rollover from any 9.2 patch to any other 9.3 patch. + - from: "9.2.*" + to: "9.3.*" diff --git a/integrationservertest/upgrade_test.go b/integrationservertest/upgrade_test.go index 663c50c294..5d98f67927 100644 --- a/integrationservertest/upgrade_test.go +++ b/integrationservertest/upgrade_test.go @@ -18,24 +18,14 @@ package integrationservertest import ( - "errors" - "fmt" - "os" "slices" "strings" "testing" - "gopkg.in/yaml.v2" - "github.com/elastic/apm-server/integrationservertest/internal/asserts" "github.com/elastic/apm-server/integrationservertest/internal/ech" ) -const ( - upgradeConfigFilename = "upgrade-config.yaml" - dockerImageOverrideFilename = "docker-image-override.yaml" -) - func formatUpgradePath(p string) string { splits := strings.Split(p, "->") for i := range splits { @@ -68,19 +58,14 @@ func TestUpgrade(t *testing.T) { versions = append(versions, version) } - config, err := parseConfig(upgradeConfigFilename) + config, err := parseConfigFile(upgradeConfigFilename) if err != nil { t.Fatal(err) } dockerImgOverride, err := parseDockerImageOverride(dockerImageOverrideFilename) if err != nil { - if errors.Is(err, os.ErrNotExist) { - // File does not exist, do nothing. - dockerImgOverride = map[ech.Version]*dockerImageOverrideConfig{} - } else { - t.Fatal(err) - } + t.Fatal(err) } t.Run(formatUpgradePath(*upgradePath), func(t *testing.T) { @@ -141,7 +126,7 @@ func buildTestSteps( // Upgrade deployment to new version and ingest. prev := versions[i-1] oldIndicesManagedBy := slices.Clone(indicesManagedBy) - if config.HasLazyRollover(prev, ver) { + if config.LazyRollover(prev, ver) { indicesManagedBy = append(indicesManagedBy, lifecycle) } steps = append(steps, @@ -203,76 +188,3 @@ func dataStreamsExpectations(expect asserts.DataStreamExpectation) map[string]as "metrics-apm.transaction.1m-%s": expect, } } - -type upgradeTest struct { - Versions []string `yaml:"versions"` -} - -type upgradeTestConfig struct { - UpgradeTests map[string]upgradeTest `yaml:"upgrade-tests"` - DataStreamLifecycle map[string]string `yaml:"data-stream-lifecycle"` - LazyRolloverWithExceptions map[string][]string `yaml:"lazy-rollover-with-exceptions"` -} - -// ExpectedLifecycle returns the lifecycle management that is expected of the provided version. -func (cfg upgradeTestConfig) ExpectedLifecycle(version ech.Version) string { - lifecycle, ok := cfg.DataStreamLifecycle[version.MajorMinor()] - if !ok { - return managedByILM - } - if strings.EqualFold(lifecycle, "DSL") { - return managedByDSL - } - return managedByILM -} - -// HasLazyRollover checks if the upgrade path is expected to have lazy rollover. -func (cfg upgradeTestConfig) HasLazyRollover(from, to ech.Version) bool { - exceptions, ok := cfg.LazyRolloverWithExceptions[to.MajorMinor()] - if !ok { - return false - } - for _, exception := range exceptions { - if strings.EqualFold(from.MajorMinor(), exception) { - return false - } - } - return true -} - -func parseConfig(filename string) (upgradeTestConfig, error) { - b, err := os.ReadFile(filename) - if err != nil { - return upgradeTestConfig{}, fmt.Errorf("failed to read %s: %w", filename, err) - } - - config := upgradeTestConfig{} - if err = yaml.Unmarshal(b, &config); err != nil { - return upgradeTestConfig{}, fmt.Errorf("failed to unmarshal upgrade test config: %w", err) - } - - return config, nil -} - -func parseDockerImageOverride(filename string) (map[ech.Version]*dockerImageOverrideConfig, error) { - b, err := os.ReadFile(filename) - if err != nil { - return nil, fmt.Errorf("failed to read %s: %w", filename, err) - } - - config := map[string]*dockerImageOverrideConfig{} - if err = yaml.Unmarshal(b, &config); err != nil { - return nil, fmt.Errorf("failed to unmarshal docker image override config: %w", err) - } - - result := map[ech.Version]*dockerImageOverrideConfig{} - for k, v := range config { - version, err := ech.NewVersionFromString(k) - if err != nil { - return nil, fmt.Errorf("invalid version in docker image override config: %w", err) - } - result[version] = v - } - - return result, nil -}