Skip to content

Commit

Permalink
feat: introduce validating webhook for FeatureFlag CR (#622)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
odubajDT and Kavindu-Dodan committed May 6, 2024
1 parent a40e13b commit c4831a3
Show file tree
Hide file tree
Showing 27 changed files with 1,058 additions and 17 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up QEMU
uses: docker/setup-qemu-action@master
with:
Expand Down Expand Up @@ -106,6 +107,7 @@ jobs:
go-version: ${{ env.DEFAULT_GO_VERSION }}
- name: Checkout
uses: actions/checkout@v4

- name: Download image
uses: actions/download-artifact@v3
with:
Expand Down
8 changes: 8 additions & 0 deletions PROJECT
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Code generated by tool. DO NOT EDIT.
# This file is used to track the info used to scaffold your project
# and allow the plugins properly work.
# More info: https://book.kubebuilder.io/reference/project-config.html
domain: openfeature.dev
layout:
- go.kubebuilder.io/v3
Expand Down Expand Up @@ -51,6 +55,10 @@ resources:
kind: FeatureFlag
path: github.com/open-feature/open-feature-operator/apis/core/v1beta1
version: v1beta1
webhooks:
defaulting: false
validation: true
webhookVersion: v1
- api:
crdVersion: v1
namespaced: true
Expand Down
7 changes: 6 additions & 1 deletion apis/core/v1beta1/featureflag_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,19 @@ type FeatureFlagSpec struct {
}

type FlagSpec struct {
Flags map[string]Flag `json:"flags"`
Flags `json:",inline"`
// +optional
// +kubebuilder:validation:Schemaless
// +kubebuilder:pruning:PreserveUnknownFields
// +kubebuilder:validation:Type=object
Evaluators json.RawMessage `json:"$evaluators,omitempty"`
}

// Flags represent the flags specification
type Flags struct {
FlagsMap map[string]Flag `json:"flags"`
}

type Flag struct {
// +kubebuilder:validation:Enum=ENABLED;DISABLED
State string `json:"state"`
Expand Down
4 changes: 2 additions & 2 deletions apis/core/v1beta1/featureflag_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func Test_FeatureFlag(t *testing.T) {
},
Spec: FeatureFlagSpec{
FlagSpec: FlagSpec{
Flags: map[string]Flag{},
Flags: Flags{},
},
},
}
Expand Down Expand Up @@ -64,7 +64,7 @@ func Test_FeatureFlag(t *testing.T) {
OwnerReferences: references,
},
Data: map[string]string{
"cmnamespace_cmname.flagd.json": "{\"flags\":{}}",
"cmnamespace_cmname.flagd.json": "{\"flags\":null}",
},
}, *cm)
}
117 changes: 117 additions & 0 deletions apis/core/v1beta1/featureflag_webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
Copyright 2022.
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 v1beta1

import (
"encoding/json"
"fmt"
"sync"

_ "embed"

schema "github.com/open-feature/flagd-schemas/json"
"github.com/xeipuuv/gojsonschema"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook"
)

// log is for logging in this package.
var featureFlagLog = logf.Log.WithName("featureflag-resource")
var compiledSchema *gojsonschema.Schema
var schemaInitOnce sync.Once

func (ff *FeatureFlag) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(ff).
Complete()
}

//+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

var _ webhook.Validator = &FeatureFlag{}

// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (ff *FeatureFlag) ValidateCreate() error {
featureFlagLog.Info("validate create", "name", ff.Name)

if err := validateFeatureFlagFlags(ff.Spec.FlagSpec.Flags); err != nil {
return err
}

return nil
}

// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
func (ff *FeatureFlag) ValidateUpdate(old runtime.Object) error {
featureFlagLog.Info("validate update", "name", ff.Name)

if err := validateFeatureFlagFlags(ff.Spec.FlagSpec.Flags); err != nil {
return err
}

return nil
}

// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
func (ff *FeatureFlag) ValidateDelete() error {
featureFlagLog.Info("validate delete", "name", ff.Name)

return nil
}

func validateFeatureFlagFlags(flags Flags) error {
b, err := json.Marshal(flags)
if err != nil {
return err
}

documentLoader := gojsonschema.NewStringLoader(string(b))

compiledSchema, err := initSchemas()
if err != nil {
return fmt.Errorf("unable to initialize Schema: %s", err.Error())
}

result, err := compiledSchema.Validate(documentLoader)
if err != nil {
return err
}

if !result.Valid() {
err = fmt.Errorf("")
for _, desc := range result.Errors() {
err = fmt.Errorf(err.Error() + desc.Description() + "\n")
}
}
return err
}

func initSchemas() (*gojsonschema.Schema, error) {
var err error
schemaInitOnce.Do(func() {
schemaLoader := gojsonschema.NewSchemaLoader()
err = schemaLoader.AddSchemas(gojsonschema.NewStringLoader(schema.TargetingSchema))
if err == nil {
compiledSchema, err = schemaLoader.Compile(gojsonschema.NewStringLoader(schema.FlagSchema))
}

})

return compiledSchema, err
}
167 changes: 167 additions & 0 deletions apis/core/v1beta1/featureflag_webhook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package v1beta1

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/require"
)

func Test_validateFeatureFlagTargeting(t *testing.T) {
tests := []struct {
name string
in Flags
wantErr bool
}{
{
name: "happy path",
in: Flags{
FlagsMap: map[string]Flag{
"fractional": {
State: "ENABLED",
Variants: json.RawMessage(`{
"clubs": "clubs",
"diamonds": "diamonds",
"hearts": "hearts",
"spades": "spades",
"none": "none"}
`),
DefaultVariant: "none",
Targeting: json.RawMessage(`{
"fractional": [
["clubs", 25],
["diamonds", 25],
["hearts", 25],
["spades", 25]
]}
`),
},
},
},
wantErr: false,
},
{
name: "happy path no targeting",
in: Flags{
FlagsMap: map[string]Flag{
"fractional": {
State: "ENABLED",
Variants: json.RawMessage(`{
"clubs": "clubs",
"diamonds": "diamonds",
"hearts": "hearts",
"spades": "spades",
"none": "none"}
`),
DefaultVariant: "none",
},
},
},
wantErr: false,
},
{
name: "fractional invalid bucketing",
in: Flags{
FlagsMap: map[string]Flag{
"fractional-invalid-bucketing": {
State: "ENABLED",
Variants: json.RawMessage(`{
"clubs": "clubs",
"diamonds": "diamonds",
"hearts": "hearts",
"spades": "spades",
"none": "none"}
`),
DefaultVariant: "none",
Targeting: json.RawMessage(`{
"fractional": [
"invalid",
["clubs", 25],
["diamonds", 25],
["hearts", 25],
["spades", 25]
]}
`),
},
},
},
wantErr: true,
},
{
name: "empty variants",
in: Flags{
FlagsMap: map[string]Flag{
"fractional-invalid-bucketing": {
State: "ENABLED",
Variants: json.RawMessage{},
DefaultVariant: "on",
},
},
},
wantErr: true,
},
{
name: "fractional invalid weighting",
in: Flags{
FlagsMap: map[string]Flag{
"fractional-invalid-weighting": {
State: "ENABLED",
Variants: json.RawMessage(`{
"clubs": "clubs",
"diamonds": "diamonds",
"hearts": "hearts",
"spades": "spades",
"none": "none"}
`),
DefaultVariant: "none",
Targeting: json.RawMessage(`{
"fractional": [
["clubs", 25],
["diamonds", "25"],
["hearts", 25],
["spades", 25]
]}
`),
},
},
},
wantErr: true,
},
{
name: "invalid-ends-with-param",
in: Flags{
FlagsMap: map[string]Flag{
"invalid-ends-with-param": {
State: "ENABLED",
Variants: json.RawMessage(`{
"prefix": 1,
"postfix": 2
}
`),
DefaultVariant: "none",
Targeting: json.RawMessage(`{
"if": [
{
"ends_with": [{ "var": "id" }, 0]
},
"postfix",
"prefix"
]
}
`),
},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.wantErr {
require.NotNil(t, validateFeatureFlagFlags(tt.in))
} else {
require.Nil(t, validateFeatureFlagFlags(tt.in))
}
})
}
}
Loading

0 comments on commit c4831a3

Please sign in to comment.