diff --git a/cmd/main.go b/cmd/main.go index ef1aec8..562a4e3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -15,13 +15,16 @@ package main import ( + "context" "crypto/tls" + "fmt" "os" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" + "k8s.io/client-go/rest" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -41,15 +44,20 @@ import ( //+kubebuilder:scaffold:imports ) +const ( + IN_PLACE_VERTICAL_POD_AUTOSCALING_FG_NAME = "InPlacePodVerticalScaling" +) + var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") leaderElectionID = "8fd077db.x-k8s.io" ) +//+kubebuilder:rbac:urls="/metrics",verbs=get + func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(autoscalingv1alpha1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme } @@ -62,6 +70,22 @@ func main() { } ctrl.SetLogger(config.Logger(cfg.ZapDevelopment, cfg.ZapLogLevel)) metrics.Register() + restConfig := ctrl.GetConfigOrDie() + + if cfg.ValidateFeatureEnabled { + setupLog.Info("validating required feature gates") + featureGates, err := getFeatureGatesFromMetrics(context.Background(), restConfig) + if err == nil { + if !featureGates.IsEnabledAnyStage(IN_PLACE_VERTICAL_POD_AUTOSCALING_FG_NAME) { + setupLog.Error( + fmt.Errorf("%s is not enabled at any stage", IN_PLACE_VERTICAL_POD_AUTOSCALING_FG_NAME), + "required feature gates are not enabled") + os.Exit(1) + } + } else { + setupLog.Error(err, "failed to validate required feature gates, continuing...") + } + } tlsOpts := []func(*tls.Config){} if !cfg.HTTP2 { @@ -76,7 +100,7 @@ func main() { Port: 9443, }) - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + mgr, err := ctrl.NewManager(restConfig, ctrl.Options{ Scheme: scheme, Metrics: metricsserver.Options{ BindAddress: cfg.MetricsProbeBindAddr, @@ -144,3 +168,11 @@ func setupControllers(mgr ctrl.Manager, boostMgr boost.Manager, cfg *config.Conf } //+kubebuilder:scaffold:builder } + +func getFeatureGatesFromMetrics(ctx context.Context, cfg *rest.Config) (util.FeatureGates, error) { + fgValidator, err := util.NewMetricsFeatureGateValidatorFromConfig(ctx, cfg) + if err != nil { + return nil, err + } + return fgValidator.GetFeatureGates() +} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 2649654..ea974f7 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,10 @@ kind: ClusterRole metadata: name: manager-role rules: +- nonResourceURLs: + - /metrics + verbs: + - get - apiGroups: - "" resources: diff --git a/go.mod b/go.mod index b4622db..3f44fa6 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/open-policy-agent/cert-controller v0.11.0 github.com/prometheus/client_golang v1.20.4 github.com/prometheus/client_model v0.6.1 + github.com/prometheus/common v0.55.0 go.uber.org/mock v0.4.0 go.uber.org/zap v1.27.0 gomodules.xyz/jsonpatch/v2 v2.4.0 @@ -52,7 +53,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect diff --git a/go.sum b/go.sum index a0e2ccd..86b2efc 100644 --- a/go.sum +++ b/go.sum @@ -177,8 +177,6 @@ k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24 k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk= k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= -k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= diff --git a/internal/config/config.go b/internal/config/config.go index aef6bb0..c21f733 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,16 +16,17 @@ package config const ( - PodNamespaceDefault = "kube-startup-cpu-boost-system" - MgrCheckIntervalSecDefault = 5 - LeaderElectionDefault = false - MetricsProbeBindAddrDefault = ":8080" - HealthProbeBindAddrDefault = ":8081" - SecureMetricsDefault = false - ZapLogLevelDefault = 0 // zapcore.InfoLevel - ZapDevelopmentDefault = false - HTTP2Default = false - RemoveLimitsDefault = true + PodNamespaceDefault = "kube-startup-cpu-boost-system" + MgrCheckIntervalSecDefault = 5 + LeaderElectionDefault = false + MetricsProbeBindAddrDefault = ":8080" + HealthProbeBindAddrDefault = ":8081" + SecureMetricsDefault = false + ZapLogLevelDefault = 0 // zapcore.InfoLevel + ZapDevelopmentDefault = false + HTTP2Default = false + RemoveLimitsDefault = true + ValidateFeatureEnabledDefault = true ) // ConfigProvider provides the Kube Startup CPU Boost configuration @@ -57,6 +58,9 @@ type Config struct { HTTP2 bool // RemoveLimits determines if CPU resource limits should be removed during boost RemoveLimits bool + // ValidateFeatureEnabled determines if InPlacePodVerticalScaling feature state + // is validated at operator's start + ValidateFeatureEnabled bool } // LoadDefaults loads the default configuration values @@ -71,4 +75,5 @@ func (c *Config) LoadDefaults() { c.ZapDevelopment = ZapDevelopmentDefault c.HTTP2 = HTTP2Default c.RemoveLimits = RemoveLimitsDefault + c.ValidateFeatureEnabled = ValidateFeatureEnabledDefault } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e9dfa41..cdf4979 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -59,5 +59,8 @@ var _ = Describe("Config", func() { It("has valid RemoveLimits", func() { Expect(cfg.RemoveLimits).To(Equal(config.RemoveLimitsDefault)) }) + It("has valid RemoveLimits", func() { + Expect(cfg.ValidateFeatureEnabled).To(Equal(config.ValidateFeatureEnabledDefault)) + }) }) }) diff --git a/internal/config/env_provider.go b/internal/config/env_provider.go index e481cff..9f86a05 100644 --- a/internal/config/env_provider.go +++ b/internal/config/env_provider.go @@ -21,16 +21,17 @@ import ( ) const ( - PodNamespaceEnvVar = "POD_NAMESPACE" - MgrCheckIntervalSecEnvVar = "MGR_CHECK_INTERVAL" - LeaderElectionEnvVar = "LEADER_ELECTION" - MetricsProbeBindAddrEnvVar = "METRICS_PROBE_BIND_ADDR" - HealthProbeBindAddrEnvVar = "HEALTH_PROBE_BIND_ADDR" - SecureMetricsEnvVar = "SECURE_METRICS" - ZapLogLevelEnvVar = "ZAP_LOG_LEVEL" - ZapDevelopmentEnvVar = "ZAP_DEVELOPMENT" - HTTP2EnvVar = "HTTP2" - RemoveLimitsEnvVar = "REMOVE_LIMITS" + PodNamespaceEnvVar = "POD_NAMESPACE" + MgrCheckIntervalSecEnvVar = "MGR_CHECK_INTERVAL" + LeaderElectionEnvVar = "LEADER_ELECTION" + MetricsProbeBindAddrEnvVar = "METRICS_PROBE_BIND_ADDR" + HealthProbeBindAddrEnvVar = "HEALTH_PROBE_BIND_ADDR" + SecureMetricsEnvVar = "SECURE_METRICS" + ZapLogLevelEnvVar = "ZAP_LOG_LEVEL" + ZapDevelopmentEnvVar = "ZAP_DEVELOPMENT" + HTTP2EnvVar = "HTTP2" + RemoveLimitsEnvVar = "REMOVE_LIMITS" + ValidateFeatureEnabledEnvVar = "VALIDATE_FEATURE_ENABLED" ) type LookupEnvFunc func(key string) (string, bool) @@ -59,6 +60,7 @@ func (p *EnvConfigProvider) LoadConfig() (*Config, error) { errs = p.loadZapDevelopment(&config, errs) errs = p.loadHTTP2(&config, errs) errs = p.loadRemoveLimits(&config, errs) + errs = p.loadValidateFeatureEnabled(&config, errs) var err error if len(errs) > 0 { err = errors.Join(errs...) @@ -160,3 +162,14 @@ func (p *EnvConfigProvider) loadRemoveLimits(config *Config, curErrs []error) (e } return } + +func (p *EnvConfigProvider) loadValidateFeatureEnabled(config *Config, curErrs []error) (errs []error) { + if v, ok := p.lookupFunc(ValidateFeatureEnabledEnvVar); ok { + boolVal, err := strconv.ParseBool(v) + config.ValidateFeatureEnabled = boolVal + if err != nil { + errs = append(curErrs, fmt.Errorf("%s value is not a bool: %s", ValidateFeatureEnabledEnvVar, err)) + } + } + return +} diff --git a/internal/config/env_provider_test.go b/internal/config/env_provider_test.go index 1353ea2..170e21f 100644 --- a/internal/config/env_provider_test.go +++ b/internal/config/env_provider_test.go @@ -139,5 +139,13 @@ var _ = Describe("EnvProvider", func() { Expect(cfg.RemoveLimits).To(BeFalse()) }) }) + When("validateFeatureEnabled variable is set", func() { + BeforeEach(func() { + lookupFuncMap[config.ValidateFeatureEnabledEnvVar] = "false" + }) + It("has valid validateFeatureEnabled", func() { + Expect(cfg.ValidateFeatureEnabled).To(BeFalse()) + }) + }) }) }) diff --git a/internal/mock/k8s_client_rest_interface.go b/internal/mock/k8s_client_rest_interface.go new file mode 100644 index 0000000..e4a586e --- /dev/null +++ b/internal/mock/k8s_client_rest_interface.go @@ -0,0 +1,168 @@ +// Copyright 2024 Google LLC +// +// 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 +// +// https://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. + +// Code generated by MockGen. DO NOT EDIT. +// Source: k8s.io/client-go/rest (interfaces: Interface) +// +// Generated by this command: +// +// mockgen -package mock --copyright_file hack/boilerplate.go.txt --destination internal/mock/k8s_client_rest_interface.go k8s.io/client-go/rest Interface +// + +package mock + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +// MockInterface is a mock of Interface interface. +type MockInterface struct { + ctrl *gomock.Controller + recorder *MockInterfaceMockRecorder +} + +// MockInterfaceMockRecorder is the mock recorder for MockInterface. +type MockInterfaceMockRecorder struct { + mock *MockInterface +} + +// NewMockInterface creates a new mock instance. +func NewMockInterface(ctrl *gomock.Controller) *MockInterface { + mock := &MockInterface{ctrl: ctrl} + mock.recorder = &MockInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder { + return m.recorder +} + +// APIVersion mocks base method. +func (m *MockInterface) APIVersion() schema.GroupVersion { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "APIVersion") + ret0, _ := ret[0].(schema.GroupVersion) + return ret0 +} + +// APIVersion indicates an expected call of APIVersion. +func (mr *MockInterfaceMockRecorder) APIVersion() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "APIVersion", reflect.TypeOf((*MockInterface)(nil).APIVersion)) +} + +// Delete mocks base method. +func (m *MockInterface) Delete() *rest.Request { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete") + ret0, _ := ret[0].(*rest.Request) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockInterfaceMockRecorder) Delete() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockInterface)(nil).Delete)) +} + +// Get mocks base method. +func (m *MockInterface) Get() *rest.Request { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get") + ret0, _ := ret[0].(*rest.Request) + return ret0 +} + +// Get indicates an expected call of Get. +func (mr *MockInterfaceMockRecorder) Get() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockInterface)(nil).Get)) +} + +// GetRateLimiter mocks base method. +func (m *MockInterface) GetRateLimiter() flowcontrol.RateLimiter { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRateLimiter") + ret0, _ := ret[0].(flowcontrol.RateLimiter) + return ret0 +} + +// GetRateLimiter indicates an expected call of GetRateLimiter. +func (mr *MockInterfaceMockRecorder) GetRateLimiter() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRateLimiter", reflect.TypeOf((*MockInterface)(nil).GetRateLimiter)) +} + +// Patch mocks base method. +func (m *MockInterface) Patch(arg0 types.PatchType) *rest.Request { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Patch", arg0) + ret0, _ := ret[0].(*rest.Request) + return ret0 +} + +// Patch indicates an expected call of Patch. +func (mr *MockInterfaceMockRecorder) Patch(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockInterface)(nil).Patch), arg0) +} + +// Post mocks base method. +func (m *MockInterface) Post() *rest.Request { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Post") + ret0, _ := ret[0].(*rest.Request) + return ret0 +} + +// Post indicates an expected call of Post. +func (mr *MockInterfaceMockRecorder) Post() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Post", reflect.TypeOf((*MockInterface)(nil).Post)) +} + +// Put mocks base method. +func (m *MockInterface) Put() *rest.Request { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Put") + ret0, _ := ret[0].(*rest.Request) + return ret0 +} + +// Put indicates an expected call of Put. +func (mr *MockInterfaceMockRecorder) Put() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Put", reflect.TypeOf((*MockInterface)(nil).Put)) +} + +// Verb mocks base method. +func (m *MockInterface) Verb(arg0 string) *rest.Request { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Verb", arg0) + ret0, _ := ret[0].(*rest.Request) + return ret0 +} + +// Verb indicates an expected call of Verb. +func (mr *MockInterfaceMockRecorder) Verb(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verb", reflect.TypeOf((*MockInterface)(nil).Verb), arg0) +} diff --git a/internal/util/fg_validator.go b/internal/util/fg_validator.go new file mode 100644 index 0000000..cfe8573 --- /dev/null +++ b/internal/util/fg_validator.go @@ -0,0 +1,145 @@ +// Copyright 2024 Google LLC +// +// 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 +// +// https://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 util + +import ( + "context" + "errors" + "strings" + + promclient "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" + "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" +) + +type FeatureGates map[string]map[string]bool +type FeatureGateStage string + +const ( + METRICS_ENDPOINT = "/metrics" + K8S_FEATURE_ENABLED_METRIC_NAME = "kubernetes_feature_enabled" + K8S_FEATURE_ENABLED_NAME_LABEL = "name" + K8S_FEATURE_ENABLED_STAGE_LABEL = "stage" + + FEATURE_GATE_STAGE_ALPHA FeatureGateStage = "ALPHA" + FEATURE_GATE_STAGE_BETA FeatureGateStage = "BETA" + FEATURE_GATE_STAGE_GA FeatureGateStage = "" + FEATURE_GATE_STAGE_DEPRECATED FeatureGateStage = "DEPRECATED" +) + +func (f FeatureGates) IsEnabled(featureGate string, stage FeatureGateStage) bool { + stages, ok := f[featureGate] + if !ok { + return false + } + for stageName, enabled := range stages { + if strings.ToUpper(stageName) == string(stage) { + return enabled + } + } + return false +} + +func (f FeatureGates) IsEnabledAnyStage(featureGate string) bool { + stages, ok := f[featureGate] + if !ok { + return false + } + for _, enabled := range stages { + if enabled { + return true + } + } + return false +} + +// FeatureGateValidator validates if a given feature gates are enabled on a cluster +type FeatureGateValidator interface { + // GetFeatureGates returns the supported feature gates + GetFeatureGates() (FeatureGates, error) +} + +// metricsFeatureGateValidator validates if a given feature gates are enabled on a cluster +// using /metrics endpoint +type metricsFeatureGateValidator struct { + client restclient.Interface + ctx context.Context +} + +func NewMetricsFeatureGateValidatorFromConfig(ctx context.Context, config *restclient.Config) (FeatureGateValidator, error) { + client, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, err + } + return NewMetricsFeatureGateValidator(ctx, client.RESTClient()), nil +} + +func NewMetricsFeatureGateValidator(ctx context.Context, RESTClient restclient.Interface) FeatureGateValidator { + return &metricsFeatureGateValidator{ + client: RESTClient, + ctx: ctx, + } +} + +func (m *metricsFeatureGateValidator) GetFeatureGates() (FeatureGates, error) { + reader, err := m.client.Get().AbsPath(METRICS_ENDPOINT).Stream(m.ctx) + if err != nil { + return nil, err + } + defer reader.Close() + parser := expfmt.TextParser{} + metricFamilies, err := parser.TextToMetricFamilies(reader) + if err != nil { + return nil, err + } + featureGates := make(map[string]map[string]bool) + for name, family := range metricFamilies { + if name != K8S_FEATURE_ENABLED_METRIC_NAME { + continue + } + for _, metric := range family.Metric { + name, stage, err := getNameAndStage(metric.GetLabel()) + if err != nil { + continue + } + if featureGates[name] == nil { + featureGates[name] = make(map[string]bool) + } + featureGates[name][stage] = false + if metric.GetGauge().GetValue() == 1 { + featureGates[name][stage] = true + } + } + } + return featureGates, nil +} + +func getNameAndStage(labels []*promclient.LabelPair) (string, string, error) { + var name string + var stagePtr *string + for _, label := range labels { + if label.GetName() == K8S_FEATURE_ENABLED_NAME_LABEL { + name = label.GetValue() + } + if label.GetName() == K8S_FEATURE_ENABLED_STAGE_LABEL { + stagePtr = label.Value + } + } + if name == "" || stagePtr == nil { + return "", "", errors.New("missing name and stage label") + } + return name, *stagePtr, nil +} diff --git a/internal/util/fg_validator_test.go b/internal/util/fg_validator_test.go new file mode 100644 index 0000000..fe60354 --- /dev/null +++ b/internal/util/fg_validator_test.go @@ -0,0 +1,159 @@ +// Copyright 2024 Google LLC +// +// 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 +// +// https://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 util_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/google/kube-startup-cpu-boost/internal/mock" + "github.com/google/kube-startup-cpu-boost/internal/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + fakerest "k8s.io/client-go/rest/fake" +) + +const ( + K8S_FG_METRIC_HELP = "# HELP kubernetes_feature_enabled [BETA] This metric records the data about the stage and enablement of a k8s feature." + K8S_FG_METRIC_TYPE = "# TYPE kubernetes_feature_enabled gauge" +) + +var _ = Describe("Feature Gate Validator", func() { + Describe("Retrieves feature gates from metrics", func() { + var ( + mockCtrl *gomock.Controller + mockClient *mock.MockInterface + mockCall *gomock.Call + respData map[string]map[string]int + metricsFgValidator util.FeatureGateValidator + featureGates util.FeatureGates + err error + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + mockClient = mock.NewMockInterface(mockCtrl) + metricsFgValidator = util.NewMetricsFeatureGateValidator(context.TODO(), mockClient) + }) + JustBeforeEach(func() { + fakeClient := fakerest.RESTClient{ + Resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser( + bytes.NewReader([]byte(featureGateString(respData))), + ), + }, + } + mockCall = mockClient.EXPECT().Get().Return(fakeClient.Request()) + featureGates, err = metricsFgValidator.GetFeatureGates() + }) + When("REST client returns error", func() { + JustBeforeEach(func() { + fakeClient := fakerest.RESTClient{ + Err: errors.New("fake error"), + } + mockCall = mockClient.EXPECT().Get().Return(fakeClient.Request()) + featureGates, err = metricsFgValidator.GetFeatureGates() + }) + It("errors", func() { + Expect(err).To(HaveOccurred()) + }) + }) + When("The kubernetes_feature_enabled metric is missing", func() { + BeforeEach(func() { + respData = make(map[string]map[string]int) + }) + It("doesn't error", func() { + Expect(err).NotTo(HaveOccurred()) + }) + It("calls GET on metrics endpoint", func() { + mockCall.Times(1) + }) + It("returns zero feature gates", func() { + Expect(len(featureGates)).To(Equal(0)) + }) + }) + When("The kubernetes_feature_enabled is present with three features", func() { + BeforeEach(func() { + respData = map[string]map[string]int{ + "InPlacePodVerticalScaling": { + "ALPHA": 0, + "BETA": 1, + }, + "MinDomainsInPodTopologySpread": { + "": 1, + }, + "NodeLogQuery": { + "BETA": 0, + }, + } + }) + It("doesn't error", func() { + Expect(err).NotTo(HaveOccurred()) + }) + It("calls GET on metrics endpoint", func() { + mockCall.Times(1) + }) + It("returns feature gates", func() { + Expect(len(featureGates)).To(Equal(3)) + }) + It("returns InPlacePodVerticalScaling with two states", func() { + Expect(len(featureGates["InPlacePodVerticalScaling"])).To(Equal(2)) + }) + It("returns InPlacePodVerticalScaling ALPHA disabled", func() { + Expect(featureGates.IsEnabled("InPlacePodVerticalScaling", "ALPHA")).To(BeFalse()) + }) + It("returns InPlacePodVerticalScaling BETA enabled", func() { + Expect(featureGates.IsEnabled("InPlacePodVerticalScaling", "BETA")).To(BeTrue()) + }) + It("returns InPlacePodVerticalScaling anyStage enabled", func() { + Expect(featureGates.IsEnabledAnyStage("InPlacePodVerticalScaling")).To(BeTrue()) + }) + It("returns MinDomainsInPodTopologySpread GA enabled", func() { + Expect(featureGates.IsEnabled("MinDomainsInPodTopologySpread", "")).To(BeTrue()) + }) + It("returns NodeLogQuery BETA disabled", func() { + Expect(featureGates.IsEnabled("NodeLogQuery", "BETA")).To(BeFalse()) + }) + It("returns NonExisting BETA disabled", func() { + Expect(featureGates.IsEnabled("NonExisting", "BETA")).To(BeFalse()) + }) + }) + }) +}) + +func featureGateString(data map[string]map[string]int) string { + builder := strings.Builder{} + builder.WriteString(K8S_FG_METRIC_HELP) + builder.WriteString("\n") + builder.WriteString(K8S_FG_METRIC_TYPE) + builder.WriteString("\n") + if data == nil { + return builder.String() + } + for fg, stageValues := range data { + for stage, value := range stageValues { + featureStr := fmt.Sprintf("kubernetes_feature_enabled{name=\"%s\",stage=\"%s\"} %d", fg, stage, value) + builder.WriteString(featureStr) + builder.WriteString("\n") + } + } + return builder.String() +} diff --git a/internal/util/util_suite_test.go b/internal/util/util_suite_test.go new file mode 100644 index 0000000..6d98371 --- /dev/null +++ b/internal/util/util_suite_test.go @@ -0,0 +1,27 @@ +// Copyright 2024 Google LLC +// +// 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 +// +// https://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 util_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestUtil(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Util Suite") +}