diff --git a/README.md b/README.md index ad385650a4..82e3068d05 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,11 @@ For advanced use the `node_exporter` can be passed an optional list of collector This can be useful for having different Prometheus servers collect specific metrics from nodes. +### Feature Flags + +Feature flags are used to enable or disable specific features. They can be set using the `--feature.gates` flag (`key1=true,key2=false,...`). The following feature flags are available: +* `ConsistentZFSLinuxMetricNames` (default: `false`): Use consistent metric names for ZFS metrics on Linux (same as BSD). + ## Development building and running Prerequisites: diff --git a/featuregate/featuregate.go b/featuregate/featuregate.go new file mode 100644 index 0000000000..8993044a04 --- /dev/null +++ b/featuregate/featuregate.go @@ -0,0 +1,143 @@ +// Copyright 2024 The Prometheus Authors +// Licensed 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 featuregate + +import ( + "fmt" + "github.com/alecthomas/kingpin/v2" + "github.com/go-kit/log" + "golang.org/x/mod/semver" + "io" + "os" +) + +const versionFilePath = "../VERSION" + +var ( + enabledDefaultFeatureGates = map[string]bool{} + gotFeatureGates = kingpin.Flag("feature-gates", "A set of key=value pairs that describe enabled feature gates for experimental features.").Default("").StringMap() +) + +type ( + FeatureGate struct { + Name string + Desc string + InitialAddingVersion string + FinalRetiringVersion string + logger log.Logger + } +) + +// NewFeatureGate creates a new feature gate, but errors if the feature gate is invalid. +func NewFeatureGate(name, desc, initialAddingVersion, finalRetiringVersion string) *FeatureGate { + return &FeatureGate{ + Name: name, + Desc: desc, + InitialAddingVersion: semver.Canonical(initialAddingVersion), + FinalRetiringVersion: semver.Canonical(finalRetiringVersion), + logger: log.NewLogfmtLogger(os.Stdout), + } +} + +func (fg *FeatureGate) String() string { + return fg.Name +} + +// IsEnabled returns true if the feature gate is enabled, false if it is disabled. +func (fg *FeatureGate) IsEnabled() (bool, error) { + _, err := fg.isValid() + if err != nil { + return false, err + } + + v, err := currentVersion() + if err != nil { + return false, err + } + + // Check if the feature gate is within the specified version range. + // Note: Do not return immediately to allow overriding the specified range enablement. + isFeatureGateWithinSpecifiedVersionRange := semver.Compare(fg.InitialAddingVersion, v) != 1 && + semver.Compare(fg.FinalRetiringVersion, v) != -1 + + // Check if the feature gate is enabled or disabled by default. + var isFeatureGateEnabledOrDisabledByDefault *bool + _, isFeatureGateInDefaultFeatureGates := enabledDefaultFeatureGates[fg.Name] + if isFeatureGateInDefaultFeatureGates { + isFeatureGateEnabledOrDisabledByDefault = ptrTo(enabledDefaultFeatureGates[fg.Name]) + } + + // Check if the feature gate is enabled or disabled by the user. + var isFeatureGateEnabledOrDisabledByFlag *bool + _, isFeatureGateInFlag := (*gotFeatureGates)[fg.Name] + if isFeatureGateInFlag { + t := (*gotFeatureGates)[fg.Name] + // Check for exact values since none of these is the default value (nil). + if t == "true" { + isFeatureGateEnabledOrDisabledByFlag = ptrTo(true) + } else if t == "false" { + isFeatureGateEnabledOrDisabledByFlag = ptrTo(false) + } + } + + // Check for overrides by the user. This taken precedence over the default states and version range. + featureGateState := isFeatureGateWithinSpecifiedVersionRange + if isFeatureGateEnabledOrDisabledByDefault != nil { + featureGateState = *isFeatureGateEnabledOrDisabledByDefault + } + if isFeatureGateEnabledOrDisabledByFlag != nil { + if isFeatureGateEnabledOrDisabledByDefault != nil && + *isFeatureGateEnabledOrDisabledByFlag != *isFeatureGateEnabledOrDisabledByDefault { + fg.logger.Log(fg.Name, "default feature gate state overridden by user, this is not recommended") + } + featureGateState = *isFeatureGateEnabledOrDisabledByFlag + } + return featureGateState, nil +} + +// isValid checks if the feature gate is valid. +// Eventually, the current version will surpass the final retiring version, so we don't check for that. +func (fg *FeatureGate) isValid() (*bool, error) { + v, err := currentVersion() + if err != nil { + return nil, err + } + if !semver.IsValid(fg.InitialAddingVersion) { + return ptrTo(false), fmt.Errorf("invalid adding version %q", fg.InitialAddingVersion) + } + if !semver.IsValid(fg.FinalRetiringVersion) { + return ptrTo(false), fmt.Errorf("invalid retiring version %q", fg.FinalRetiringVersion) + } + if semver.Compare(fg.InitialAddingVersion, fg.FinalRetiringVersion) != -1 { + return ptrTo(false), fmt.Errorf("adding version %q is not before the retiring version %q", fg.InitialAddingVersion, fg.FinalRetiringVersion) + } + if semver.Compare(fg.InitialAddingVersion, v) == 1 { + return ptrTo(false), fmt.Errorf("adding version %q is greater than the current version %q", fg.InitialAddingVersion, v) + } + return ptrTo(true), nil +} + +// currentVersion reads the current version from the VERSION file. +func currentVersion() (string, error) { + file, err := os.Open(versionFilePath) + if err != nil { + return "", err + } + defer file.Close() + version, err := io.ReadAll(file) + if err != nil { + return "", err + } + return "v" + string(version[:len(version)-1]), nil +} diff --git a/featuregate/featuregate_test.go b/featuregate/featuregate_test.go new file mode 100644 index 0000000000..125c68ecf4 --- /dev/null +++ b/featuregate/featuregate_test.go @@ -0,0 +1,142 @@ +// Copyright 2024 The Prometheus Authors +// Licensed 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 featuregate + +import ( + "fmt" + "golang.org/x/mod/semver" + "os" + "testing" +) + +func TestFeatureGate(t *testing.T) { + // Set a valid version range. + cleanupFn, err := changeVersionTo("v1.8.1") + if err != nil { + t.Fatal(err) + } + defer func() { + err = cleanupFn() + if err != nil { + t.Fatal(err) + } + }() + + // Default enabled or disabled feature gates. + enabledDefaultFeatureGates = map[string]bool{ + "foo": true, + "bar": false, + "baz": false, + } + + // User enabled or disabled feature gates. + gotFeatureGates = &map[string]string{ + "foo": "false", + "bar": "true", + "que": "false", + } + + // Check feature gate enablement under various parameters. + testcases := []struct { + name string + fg *FeatureGate + enabled bool + shouldError bool + }{ + { + name: "feature gate 'foo', should be disabled due to user override", + fg: NewFeatureGate("TestFoo", "", "v1.8.0", "v1.8.2"), + }, + { + name: "feature gate 'bar', should be enabled due to user override", + fg: NewFeatureGate("TestBar", "", "v1.8.0", "v1.8.2"), + shouldError: true, + }, + { + name: "feature gate 'baz', should be enabled by default", + fg: NewFeatureGate("TestBaz", "", "v1.8.0", "v1.8.2"), + shouldError: true, + }, + { + name: "feature gate 'que', should be disabled due to user override", + fg: NewFeatureGate("TestQue", "", "v1.8.0", "v1.8.2"), + }, + { + name: "feature gate 'qux', should be enabled due to valid version range", + fg: NewFeatureGate("TestQux", "", "v1.8.0", "v1.8.2"), + enabled: true, + }, + { + name: "feature gate 'qux', should be disabled due to invalid version range", + fg: NewFeatureGate("TestQux", "", "v1", "v1.8.0"), + shouldError: true, + }, + { + name: "feature gate 'qux', should be disabled due to invalid version range", + fg: NewFeatureGate("TestQux", "", "v1.8.2", "v2"), + shouldError: true, + }, + { + name: "feature gate 'qux', should be disabled due to invalid version range", + fg: NewFeatureGate("TestQux", "", "v1.8.2", "v1.8.0"), + shouldError: true, + }, + { + name: "feature gate 'qux', should be disabled due to invalid version range", + fg: NewFeatureGate("TestQux", "", "1.8.0", "v1.8.2"), + shouldError: true, + }, + { + name: "feature gate 'qux', should be disabled due to invalid version range", + fg: NewFeatureGate("TestQux", "", "v1.8.0", "1.8.2"), + shouldError: true, + }, + } + for _, testcase := range testcases { + t.Run(testcase.name, func(t *testing.T) { + got, err := testcase.fg.IsEnabled() + if err != nil && !testcase.shouldError { + t.Fatalf("unexpected error: %v", err) + } + if got != testcase.enabled { + t.Fatalf("expected %v, got %v", testcase.enabled, got) + } + }) + } +} + +func changeVersionTo(version string) (func() error, error) { + if isValid := semver.IsValid(version); !isValid { + return nil, fmt.Errorf("invalid version: %v", version) + } + v, err := currentVersion() + if err != nil { + return nil, err + } + if isValid := semver.IsValid(v); !isValid { + return nil, fmt.Errorf("invalid version: %v", v) + } + cleanupFn := func() error { + err := os.WriteFile(versionFilePath, []byte(v + "\n")[1:], 0644) + if err != nil { + return err + } + return nil + } + err = os.WriteFile(versionFilePath, []byte(version + "\n")[1:], 0644) + if err != nil { + return nil, err + } + return cleanupFn, nil +} diff --git a/featuregate/utils.go b/featuregate/utils.go new file mode 100644 index 0000000000..53db0eedcf --- /dev/null +++ b/featuregate/utils.go @@ -0,0 +1,5 @@ +package featuregate + +func ptrTo[T any](v T) *T { + return &v +} diff --git a/go.mod b/go.mod index d11c69e311..9cc783dc76 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/prometheus/procfs v0.14.0 github.com/safchain/ethtool v0.3.0 golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f + golang.org/x/mod v0.17.0 golang.org/x/sys v0.19.0 howett.net/plist v1.0.1 ) diff --git a/go.sum b/go.sum index 5e0f6a48b2..9b202fa791 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=