Skip to content

Commit

Permalink
enhancement: add feature gate support
Browse files Browse the repository at this point in the history
Allow APIs to be gated conditionally.

Signed-off-by: Pranshu Srivastava <rexagod@gmail.com>
  • Loading branch information
rexagod committed May 23, 2024
1 parent 3afc0a3 commit c2dac41
Show file tree
Hide file tree
Showing 6 changed files with 298 additions and 0 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
143 changes: 143 additions & 0 deletions featuregate/featuregate.go
Original file line number Diff line number Diff line change
@@ -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
}
142 changes: 142 additions & 0 deletions featuregate/featuregate_test.go
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions featuregate/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package featuregate

func ptrTo[T any](v T) *T {
return &v
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down

0 comments on commit c2dac41

Please sign in to comment.