From 50dfd33598ad61fa8c9c9b729ad46ac8e99299ae Mon Sep 17 00:00:00 2001 From: Uwe Krueger Date: Wed, 27 Nov 2024 11:49:05 +0100 Subject: [PATCH] feature gates --- .../attrs/featuregatesattr/attr.go | 199 ++++++++++++++++++ api/datacontext/attrs/init.go | 1 + .../config/featuregates/config_test.go | 76 +++++++ .../config/featuregates/suite_test.go | 13 ++ api/datacontext/config/featuregates/type.go | 69 ++++++ api/datacontext/config/init.go | 1 + api/datacontext/context.go | 2 + 7 files changed, 361 insertions(+) create mode 100644 api/datacontext/attrs/featuregatesattr/attr.go create mode 100644 api/datacontext/config/featuregates/config_test.go create mode 100644 api/datacontext/config/featuregates/suite_test.go create mode 100644 api/datacontext/config/featuregates/type.go diff --git a/api/datacontext/attrs/featuregatesattr/attr.go b/api/datacontext/attrs/featuregatesattr/attr.go new file mode 100644 index 0000000000..20e788d0d8 --- /dev/null +++ b/api/datacontext/attrs/featuregatesattr/attr.go @@ -0,0 +1,199 @@ +package featuregatesattr + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/mandelsoft/goutils/general" + "sigs.k8s.io/yaml" + + "ocm.software/ocm/api/datacontext" + "ocm.software/ocm/api/utils/runtime" +) + +const ( + ATTR_KEY = "ocm.software/ocm/feature-gates" + ATTR_SHORT = "featuregates" +) + +func init() { + datacontext.RegisterAttributeType(ATTR_KEY, AttributeType{}, ATTR_SHORT) +} + +type AttributeType struct{} + +func (a AttributeType) Name() string { + return ATTR_KEY +} + +func (a AttributeType) Description() string { + return ` +*featuregates* Enable/Disable optional features of the OCM library. Optional, +particular features modes and attributes can be configured, if supported by +the feature implementation. +` +} + +func (a AttributeType) Encode(v interface{}, marshaller runtime.Marshaler) ([]byte, error) { + switch t := v.(type) { + case *Attribute: + return json.Marshal(v) + case string: + _, err := a.Decode([]byte(t), runtime.DefaultYAMLEncoding) + if err != nil { + return nil, err + } + return []byte(t), nil + case []byte: + _, err := a.Decode(t, runtime.DefaultYAMLEncoding) + if err != nil { + return nil, err + } + return t, nil + default: + return nil, fmt.Errorf("feature gate config required") + } +} + +func (a AttributeType) Decode(data []byte, unmarshaller runtime.Unmarshaler) (interface{}, error) { + var c Attribute + err := yaml.Unmarshal(data, &c) + if err != nil { + return nil, err + } + return &c, nil +} + +//////////////////////////////////////////////////////////////////////////////// + +const FEATURE_DISABLED = "off" + +type Attribute struct { + lock sync.Mutex + + Features map[string]*FeatureGate `json:"features"` +} + +// FeatureGate store settings for a particular feature gate. +// To be extended by additional config possibility. +// Default behavior is to be enabled if entry is given +// for a feature name and mode is not equal *off*. +type FeatureGate struct { + Mode string `json:"mode"` + Attributes map[string]json.RawMessage `json:"attributes,omitempty"` +} + +func New() *Attribute { + return &Attribute{Features: map[string]*FeatureGate{}} +} + +func (a *Attribute) EnableFeature(name string, state *FeatureGate) { + a.lock.Lock() + defer a.lock.Unlock() + + if state == nil { + state = &FeatureGate{} + } + if state.Mode == FEATURE_DISABLED { + state.Mode = "" + } + a.Features[name] = state +} + +func (a *Attribute) SetFeature(name string, state *FeatureGate) { + a.lock.Lock() + defer a.lock.Unlock() + + if state == nil { + state = &FeatureGate{} + } + a.Features[name] = state +} + +func (a *Attribute) DisableFeature(name string) { + a.lock.Lock() + defer a.lock.Unlock() + + a.Features[name] = &FeatureGate{Mode: "off"} +} + +func (a *Attribute) DefaultFeature(name string) { + a.lock.Lock() + defer a.lock.Unlock() + + delete(a.Features, name) +} + +func (a *Attribute) IsEnabled(name string, def ...bool) bool { + return a.GetFeature(name, def...).Mode != FEATURE_DISABLED +} + +func (a *Attribute) GetFeature(name string, def ...bool) *FeatureGate { + a.lock.Lock() + defer a.lock.Unlock() + + g, ok := a.Features[name] + if !ok { + g = &FeatureGate{} + if !general.Optional(def...) { + g.Mode = FEATURE_DISABLED + } + } + return g +} + +//////////////////////////////////////////////////////////////////////////////// + +func Get(ctx datacontext.Context) *Attribute { + v := ctx.GetAttributes().GetAttribute(ATTR_KEY) + if v == nil { + v = New() + } + return v.(*Attribute) +} + +func Set(ctx datacontext.Context, c *Attribute) { + ctx.GetAttributes().SetAttribute(ATTR_KEY, c) +} + +var lock sync.Mutex + +func get(ctx datacontext.Context) *Attribute { + attrs := ctx.GetAttributes() + v := attrs.GetAttribute(ATTR_KEY) + + if v == nil { + v = New() + attrs.SetAttribute(ATTR_KEY, v) + } + return v.(*Attribute) +} + +func SetFeature(ctx datacontext.Context, name string, state *FeatureGate) { + lock.Lock() + defer lock.Unlock() + + get(ctx).SetFeature(name, state) +} + +func EnableFeature(ctx datacontext.Context, name string, state *FeatureGate) { + lock.Lock() + defer lock.Unlock() + + get(ctx).EnableFeature(name, state) +} + +func DisableFeature(ctx datacontext.Context, name string) { + lock.Lock() + defer lock.Unlock() + + get(ctx).DisableFeature(name) +} + +func DefaultFeature(ctx datacontext.Context, name string) { + lock.Lock() + defer lock.Unlock() + + get(ctx).DefaultFeature(name) +} diff --git a/api/datacontext/attrs/init.go b/api/datacontext/attrs/init.go index 9b87f58cb3..0230b3de92 100644 --- a/api/datacontext/attrs/init.go +++ b/api/datacontext/attrs/init.go @@ -1,6 +1,7 @@ package attrs import ( + _ "ocm.software/ocm/api/datacontext/attrs/featuregatesattr" _ "ocm.software/ocm/api/datacontext/attrs/logforward" _ "ocm.software/ocm/api/datacontext/attrs/rootcertsattr" _ "ocm.software/ocm/api/datacontext/attrs/tmpcache" diff --git a/api/datacontext/config/featuregates/config_test.go b/api/datacontext/config/featuregates/config_test.go new file mode 100644 index 0000000000..956ac4a17c --- /dev/null +++ b/api/datacontext/config/featuregates/config_test.go @@ -0,0 +1,76 @@ +package featuregates_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "ocm.software/ocm/api/datacontext/attrs/featuregatesattr" + "ocm.software/ocm/api/datacontext/config/attrs" + "ocm.software/ocm/api/datacontext/config/featuregates" + + "ocm.software/ocm/api/config" + "ocm.software/ocm/api/datacontext" +) + +var _ = Describe("feature gates", func() { + var ctx config.Context + + BeforeEach(func() { + ctx = config.WithSharedAttributes(datacontext.New(nil)).New() + }) + + Context("applies", func() { + It("handles default", func() { + a := featuregatesattr.Get(ctx) + + Expect(a.IsEnabled("test")).To(BeFalse()) + Expect(a.IsEnabled("test", true)).To(BeTrue()) + g := a.GetFeature("test", true) + Expect(g).NotTo(BeNil()) + Expect(g.Mode).To(Equal("")) + }) + + It("enables feature", func() { + cfg := featuregates.New() + cfg.EnableFeature("test", &featuregates.FeatureGate{Mode: "on"}) + ctx.ApplyConfig(cfg, "manual") + + a := featuregatesattr.Get(ctx) + + Expect(a.IsEnabled("test")).To(BeTrue()) + Expect(a.IsEnabled("test", true)).To(BeTrue()) + g := a.GetFeature("test") + Expect(g).NotTo(BeNil()) + Expect(g.Mode).To(Equal("on")) + }) + + It("disables feature", func() { + cfg := featuregates.New() + cfg.DisableFeature("test") + ctx.ApplyConfig(cfg, "manual") + + a := featuregatesattr.Get(ctx) + + Expect(a.IsEnabled("test")).To(BeFalse()) + Expect(a.IsEnabled("test", true)).To(BeFalse()) + }) + + It("handle attribute config", func() { + cfg := featuregatesattr.New() + cfg.EnableFeature("test", &featuregates.FeatureGate{Mode: "on"}) + + spec := attrs.New() + Expect(spec.AddAttribute(featuregatesattr.ATTR_KEY, cfg)).To(Succeed()) + Expect(ctx.ApplyConfig(spec, "test")).To(Succeed()) + + ctx.ApplyConfig(spec, "manual") + + a := featuregatesattr.Get(ctx) + + Expect(a.IsEnabled("test")).To(BeTrue()) + g := a.GetFeature("test") + Expect(g).NotTo(BeNil()) + Expect(g.Mode).To(Equal("on")) + }) + + }) +}) diff --git a/api/datacontext/config/featuregates/suite_test.go b/api/datacontext/config/featuregates/suite_test.go new file mode 100644 index 0000000000..e2428a77c7 --- /dev/null +++ b/api/datacontext/config/featuregates/suite_test.go @@ -0,0 +1,13 @@ +package featuregates_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Feature Gates Config Test Suite") +} diff --git a/api/datacontext/config/featuregates/type.go b/api/datacontext/config/featuregates/type.go new file mode 100644 index 0000000000..54d58c1d34 --- /dev/null +++ b/api/datacontext/config/featuregates/type.go @@ -0,0 +1,69 @@ +package featuregates + +import ( + cfgcpi "ocm.software/ocm/api/config/cpi" + "ocm.software/ocm/api/datacontext/attrs/featuregatesattr" + "ocm.software/ocm/api/utils/runtime" +) + +const ( + ConfigType = "featuregates" + cfgcpi.OCM_CONFIG_TYPE_SUFFIX + ConfigTypeV1 = ConfigType + runtime.VersionSeparator + "v1" +) + +func init() { + cfgcpi.RegisterConfigType(cfgcpi.NewConfigType[*Config](ConfigType, usage)) + cfgcpi.RegisterConfigType(cfgcpi.NewConfigType[*Config](ConfigTypeV1, usage)) +} + +type FeatureGate = featuregatesattr.FeatureGate + +// Config describes a memory based repository interface. +type Config struct { + runtime.ObjectVersionedType `json:",inline"` + featuregatesattr.Attribute `json:",inline"` +} + +// New creates a new memory ConfigSpec. +func New() *Config { + return &Config{ + ObjectVersionedType: runtime.NewVersionedTypedObject(ConfigType), + Attribute: *featuregatesattr.New(), + } +} + +func (a *Config) GetType() string { + return ConfigType +} + +func (a *Config) ApplyTo(ctx cfgcpi.Context, target interface{}) error { + t, ok := target.(cfgcpi.Context) + if !ok { + return cfgcpi.ErrNoContext(ConfigType) + } + if len(a.Features) == 0 { + return nil + } + for n, g := range a.Features { + featuregatesattr.SetFeature(t, n, g) + } + return nil +} + +const usage = ` +The config type ` + ConfigType + ` can be used to define a list +of feature gate settings: + +
+    type: ` + ConfigType + `
+    features:
+       <name>: {
+          mode: off | <any key to enable>
+          attributes: {
+             <name>: <any yaml value>
+             ...
+          }
+       }
+       ...
+
+` diff --git a/api/datacontext/config/init.go b/api/datacontext/config/init.go index 8655415cd0..cc0ec14664 100644 --- a/api/datacontext/config/init.go +++ b/api/datacontext/config/init.go @@ -2,5 +2,6 @@ package config import ( _ "ocm.software/ocm/api/datacontext/config/attrs" + _ "ocm.software/ocm/api/datacontext/config/featuregates" _ "ocm.software/ocm/api/datacontext/config/logging" ) diff --git a/api/datacontext/context.go b/api/datacontext/context.go index d883bec958..f13aa74954 100644 --- a/api/datacontext/context.go +++ b/api/datacontext/context.go @@ -383,6 +383,8 @@ func (c *_attributes) SetAttribute(name string, value interface{}) error { if *c.updater != nil { (*c.updater).Update() } + } else { + ocmlog.Logger().LogError(err, "cannot set context attribute", "attr", name, "value", value) } return err }