Skip to content

Commit c4831a3

Browse files
feat: introduce validating webhook for FeatureFlag CR (#622)
Signed-off-by: odubajDT <ondrej.dubaj@dynatrace.com> Signed-off-by: odubajDT <93584209+odubajDT@users.noreply.github.com> Co-authored-by: Kavindu Dodanduwa <Kavindu-Dodan@users.noreply.github.com>
1 parent a40e13b commit c4831a3

27 files changed

+1058
-17
lines changed

.github/workflows/pr-checks.yml

+2
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ jobs:
5959
steps:
6060
- name: Checkout
6161
uses: actions/checkout@v4
62+
6263
- name: Set up QEMU
6364
uses: docker/setup-qemu-action@master
6465
with:
@@ -106,6 +107,7 @@ jobs:
106107
go-version: ${{ env.DEFAULT_GO_VERSION }}
107108
- name: Checkout
108109
uses: actions/checkout@v4
110+
109111
- name: Download image
110112
uses: actions/download-artifact@v3
111113
with:

PROJECT

+8
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# Code generated by tool. DO NOT EDIT.
2+
# This file is used to track the info used to scaffold your project
3+
# and allow the plugins properly work.
4+
# More info: https://book.kubebuilder.io/reference/project-config.html
15
domain: openfeature.dev
26
layout:
37
- go.kubebuilder.io/v3
@@ -51,6 +55,10 @@ resources:
5155
kind: FeatureFlag
5256
path: github.com/open-feature/open-feature-operator/apis/core/v1beta1
5357
version: v1beta1
58+
webhooks:
59+
defaulting: false
60+
validation: true
61+
webhookVersion: v1
5462
- api:
5563
crdVersion: v1
5664
namespaced: true

apis/core/v1beta1/featureflag_types.go

+6-1
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,19 @@ type FeatureFlagSpec struct {
3131
}
3232

3333
type FlagSpec struct {
34-
Flags map[string]Flag `json:"flags"`
34+
Flags `json:",inline"`
3535
// +optional
3636
// +kubebuilder:validation:Schemaless
3737
// +kubebuilder:pruning:PreserveUnknownFields
3838
// +kubebuilder:validation:Type=object
3939
Evaluators json.RawMessage `json:"$evaluators,omitempty"`
4040
}
4141

42+
// Flags represent the flags specification
43+
type Flags struct {
44+
FlagsMap map[string]Flag `json:"flags"`
45+
}
46+
4247
type Flag struct {
4348
// +kubebuilder:validation:Enum=ENABLED;DISABLED
4449
State string `json:"state"`

apis/core/v1beta1/featureflag_types_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func Test_FeatureFlag(t *testing.T) {
2727
},
2828
Spec: FeatureFlagSpec{
2929
FlagSpec: FlagSpec{
30-
Flags: map[string]Flag{},
30+
Flags: Flags{},
3131
},
3232
},
3333
}
@@ -64,7 +64,7 @@ func Test_FeatureFlag(t *testing.T) {
6464
OwnerReferences: references,
6565
},
6666
Data: map[string]string{
67-
"cmnamespace_cmname.flagd.json": "{\"flags\":{}}",
67+
"cmnamespace_cmname.flagd.json": "{\"flags\":null}",
6868
},
6969
}, *cm)
7070
}
+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
Copyright 2022.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1beta1
18+
19+
import (
20+
"encoding/json"
21+
"fmt"
22+
"sync"
23+
24+
_ "embed"
25+
26+
schema "github.com/open-feature/flagd-schemas/json"
27+
"github.com/xeipuuv/gojsonschema"
28+
"k8s.io/apimachinery/pkg/runtime"
29+
ctrl "sigs.k8s.io/controller-runtime"
30+
logf "sigs.k8s.io/controller-runtime/pkg/log"
31+
"sigs.k8s.io/controller-runtime/pkg/webhook"
32+
)
33+
34+
// log is for logging in this package.
35+
var featureFlagLog = logf.Log.WithName("featureflag-resource")
36+
var compiledSchema *gojsonschema.Schema
37+
var schemaInitOnce sync.Once
38+
39+
func (ff *FeatureFlag) SetupWebhookWithManager(mgr ctrl.Manager) error {
40+
return ctrl.NewWebhookManagedBy(mgr).
41+
For(ff).
42+
Complete()
43+
}
44+
45+
//+kubebuilder:webhook:path=/validate-core-openfeature-dev-v1beta1-featureflag,mutating=false,failurePolicy=fail,sideEffects=None,groups=core.openfeature.dev,resources=featureflags,verbs=create;update,versions=v1beta1,name=vfeatureflag.kb.io,admissionReviewVersions=v1
46+
47+
var _ webhook.Validator = &FeatureFlag{}
48+
49+
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
50+
func (ff *FeatureFlag) ValidateCreate() error {
51+
featureFlagLog.Info("validate create", "name", ff.Name)
52+
53+
if err := validateFeatureFlagFlags(ff.Spec.FlagSpec.Flags); err != nil {
54+
return err
55+
}
56+
57+
return nil
58+
}
59+
60+
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
61+
func (ff *FeatureFlag) ValidateUpdate(old runtime.Object) error {
62+
featureFlagLog.Info("validate update", "name", ff.Name)
63+
64+
if err := validateFeatureFlagFlags(ff.Spec.FlagSpec.Flags); err != nil {
65+
return err
66+
}
67+
68+
return nil
69+
}
70+
71+
// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
72+
func (ff *FeatureFlag) ValidateDelete() error {
73+
featureFlagLog.Info("validate delete", "name", ff.Name)
74+
75+
return nil
76+
}
77+
78+
func validateFeatureFlagFlags(flags Flags) error {
79+
b, err := json.Marshal(flags)
80+
if err != nil {
81+
return err
82+
}
83+
84+
documentLoader := gojsonschema.NewStringLoader(string(b))
85+
86+
compiledSchema, err := initSchemas()
87+
if err != nil {
88+
return fmt.Errorf("unable to initialize Schema: %s", err.Error())
89+
}
90+
91+
result, err := compiledSchema.Validate(documentLoader)
92+
if err != nil {
93+
return err
94+
}
95+
96+
if !result.Valid() {
97+
err = fmt.Errorf("")
98+
for _, desc := range result.Errors() {
99+
err = fmt.Errorf(err.Error() + desc.Description() + "\n")
100+
}
101+
}
102+
return err
103+
}
104+
105+
func initSchemas() (*gojsonschema.Schema, error) {
106+
var err error
107+
schemaInitOnce.Do(func() {
108+
schemaLoader := gojsonschema.NewSchemaLoader()
109+
err = schemaLoader.AddSchemas(gojsonschema.NewStringLoader(schema.TargetingSchema))
110+
if err == nil {
111+
compiledSchema, err = schemaLoader.Compile(gojsonschema.NewStringLoader(schema.FlagSchema))
112+
}
113+
114+
})
115+
116+
return compiledSchema, err
117+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package v1beta1
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func Test_validateFeatureFlagTargeting(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
in Flags
14+
wantErr bool
15+
}{
16+
{
17+
name: "happy path",
18+
in: Flags{
19+
FlagsMap: map[string]Flag{
20+
"fractional": {
21+
State: "ENABLED",
22+
Variants: json.RawMessage(`{
23+
"clubs": "clubs",
24+
"diamonds": "diamonds",
25+
"hearts": "hearts",
26+
"spades": "spades",
27+
"none": "none"}
28+
`),
29+
DefaultVariant: "none",
30+
Targeting: json.RawMessage(`{
31+
"fractional": [
32+
["clubs", 25],
33+
["diamonds", 25],
34+
["hearts", 25],
35+
["spades", 25]
36+
]}
37+
`),
38+
},
39+
},
40+
},
41+
wantErr: false,
42+
},
43+
{
44+
name: "happy path no targeting",
45+
in: Flags{
46+
FlagsMap: map[string]Flag{
47+
"fractional": {
48+
State: "ENABLED",
49+
Variants: json.RawMessage(`{
50+
"clubs": "clubs",
51+
"diamonds": "diamonds",
52+
"hearts": "hearts",
53+
"spades": "spades",
54+
"none": "none"}
55+
`),
56+
DefaultVariant: "none",
57+
},
58+
},
59+
},
60+
wantErr: false,
61+
},
62+
{
63+
name: "fractional invalid bucketing",
64+
in: Flags{
65+
FlagsMap: map[string]Flag{
66+
"fractional-invalid-bucketing": {
67+
State: "ENABLED",
68+
Variants: json.RawMessage(`{
69+
"clubs": "clubs",
70+
"diamonds": "diamonds",
71+
"hearts": "hearts",
72+
"spades": "spades",
73+
"none": "none"}
74+
`),
75+
DefaultVariant: "none",
76+
Targeting: json.RawMessage(`{
77+
"fractional": [
78+
"invalid",
79+
["clubs", 25],
80+
["diamonds", 25],
81+
["hearts", 25],
82+
["spades", 25]
83+
]}
84+
`),
85+
},
86+
},
87+
},
88+
wantErr: true,
89+
},
90+
{
91+
name: "empty variants",
92+
in: Flags{
93+
FlagsMap: map[string]Flag{
94+
"fractional-invalid-bucketing": {
95+
State: "ENABLED",
96+
Variants: json.RawMessage{},
97+
DefaultVariant: "on",
98+
},
99+
},
100+
},
101+
wantErr: true,
102+
},
103+
{
104+
name: "fractional invalid weighting",
105+
in: Flags{
106+
FlagsMap: map[string]Flag{
107+
"fractional-invalid-weighting": {
108+
State: "ENABLED",
109+
Variants: json.RawMessage(`{
110+
"clubs": "clubs",
111+
"diamonds": "diamonds",
112+
"hearts": "hearts",
113+
"spades": "spades",
114+
"none": "none"}
115+
`),
116+
DefaultVariant: "none",
117+
Targeting: json.RawMessage(`{
118+
"fractional": [
119+
["clubs", 25],
120+
["diamonds", "25"],
121+
["hearts", 25],
122+
["spades", 25]
123+
]}
124+
`),
125+
},
126+
},
127+
},
128+
wantErr: true,
129+
},
130+
{
131+
name: "invalid-ends-with-param",
132+
in: Flags{
133+
FlagsMap: map[string]Flag{
134+
"invalid-ends-with-param": {
135+
State: "ENABLED",
136+
Variants: json.RawMessage(`{
137+
"prefix": 1,
138+
"postfix": 2
139+
}
140+
`),
141+
DefaultVariant: "none",
142+
Targeting: json.RawMessage(`{
143+
"if": [
144+
{
145+
"ends_with": [{ "var": "id" }, 0]
146+
},
147+
"postfix",
148+
"prefix"
149+
]
150+
}
151+
`),
152+
},
153+
},
154+
},
155+
wantErr: true,
156+
},
157+
}
158+
for _, tt := range tests {
159+
t.Run(tt.name, func(t *testing.T) {
160+
if tt.wantErr {
161+
require.NotNil(t, validateFeatureFlagFlags(tt.in))
162+
} else {
163+
require.Nil(t, validateFeatureFlagFlags(tt.in))
164+
}
165+
})
166+
}
167+
}

0 commit comments

Comments
 (0)