diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 88b2a5787..e2522d7dc 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -34,6 +34,9 @@ jobs: go-version: ${{ env.GO_VERSION }} check-latest: true + - name: Workspace Init + run: make workspace-init + - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: diff --git a/Makefile b/Makefile index 5d0a09b00..6c7d9570f 100644 --- a/Makefile +++ b/Makefile @@ -257,7 +257,9 @@ helm-package: set-helm-overlay generate release-manifests helm install-mockgen: go install github.com/golang/mock/mockgen@v1.6.0 mockgen: install-mockgen - mockgen -source=controllers/common/flagd-injector.go -destination=controllers/common/mock/flagd-injector.go -package=commonmock + mockgen -source=./common/flagdinjector/flagdinjector.go -destination=./common/flagdinjector/mock/flagd-injector.go -package=commonmock + mockgen -source=./controllers/core/flagd/controller.go -destination=controllers/core/flagd/mock/mock.go -package=commonmock + mockgen -source=./controllers/core/flagd/resources/interface.go -destination=controllers/core/flagd/resources/mock/mock.go -package=commonmock workspace-init: workspace-clean go work init diff --git a/PROJECT b/PROJECT index 337783678..f7e96df06 100644 --- a/PROJECT +++ b/PROJECT @@ -67,4 +67,13 @@ resources: kind: FeatureFlagSource path: github.com/open-feature/open-feature-operator/apis/core/v1beta1 version: v1beta1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: openfeature.dev + group: core + kind: Flagd + path: github.com/open-feature/open-feature-operator/apis/core/v1beta1 + version: v1beta1 version: "3" diff --git a/apis/core/v1beta1/flagd_types.go b/apis/core/v1beta1/flagd_types.go new file mode 100644 index 000000000..bd1909fd4 --- /dev/null +++ b/apis/core/v1beta1/flagd_types.go @@ -0,0 +1,118 @@ +/* +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 ( + v1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// FlagdSpec defines the desired state of Flagd +type FlagdSpec struct { + // Replicas defines the number of replicas to create for the service. + // Default: 1 + // +optional + // +kubebuilder:default=1 + Replicas *int32 `json:"replicas,omitempty"` + + // ServiceType represents the type of Service to create. + // Must be one of: ClusterIP, NodePort, LoadBalancer, and ExternalName. + // Default: ClusterIP + // +optional + // +kubebuilder:default=ClusterIP + // +kubebuilder:validation:Enum:=ClusterIP;NodePort;LoadBalancer;ExternalName + ServiceType v1.ServiceType `json:"serviceType,omitempty"` + + // ServiceAccountName the service account name for the flagd deployment + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` + + // FeatureFlagSource references to a FeatureFlagSource from which the created flagd instance retrieves + // the feature flag configurations + FeatureFlagSource string `json:"featureFlagSource"` + + // Ingress + // +optional + Ingress IngressSpec `json:"ingress"` +} + +// IngressSpec defines the options to be used when deploying the ingress for flagd +type IngressSpec struct { + // Enabled enables/disables the ingress for flagd + Enabled bool `json:"enabled,omitempty"` + + // Annotations the annotations to be added to the ingress + // +optional + Annotations map[string]string `json:"annotations,omitempty"` + + // Hosts list of hosts to be added to the ingress + // +optional + Hosts []string `json:"hosts,omitempty"` + + // TLS configuration for the ingress + TLS []networkingv1.IngressTLS `json:"tls,omitempty"` + + // IngressClassName defines the name if the ingress class to be used for flagd + // +optional + IngressClassName *string `json:"ingressClassName,omitempty"` + + // PathType is the path type to be used for the ingress rules + // +optional + PathType networkingv1.PathType `json:"pathType,omitempty"` + + // FlagdPath is the path to be used for accessing the flagd flag evaluation API + // +optional + FlagdPath string `json:"flagdPath,omitempty"` + + // OFREPPath is the path to be used for accessing the OFREP API + // +optional + OFREPPath string `json:"ofrepPath"` + + // SyncPath is the path to be used for accessing the sync API + // +optional + SyncPath string `json:"syncPath"` +} + +// FlagdStatus defines the observed state of Flagd +type FlagdStatus struct { +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// Flagd is the Schema for the flagds API +type Flagd struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec FlagdSpec `json:"spec,omitempty"` + Status FlagdStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// FlagdList contains a list of Flagd +type FlagdList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Flagd `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Flagd{}, &FlagdList{}) +} diff --git a/apis/core/v1beta1/zz_generated.deepcopy.go b/apis/core/v1beta1/zz_generated.deepcopy.go index b30239935..0f43472f9 100644 --- a/apis/core/v1beta1/zz_generated.deepcopy.go +++ b/apis/core/v1beta1/zz_generated.deepcopy.go @@ -23,6 +23,7 @@ package v1beta1 import ( "encoding/json" "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -284,6 +285,101 @@ func (in *FlagSpec) DeepCopy() *FlagSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Flagd) DeepCopyInto(out *Flagd) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Flagd. +func (in *Flagd) DeepCopy() *Flagd { + if in == nil { + return nil + } + out := new(Flagd) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Flagd) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FlagdList) DeepCopyInto(out *FlagdList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Flagd, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FlagdList. +func (in *FlagdList) DeepCopy() *FlagdList { + if in == nil { + return nil + } + out := new(FlagdList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FlagdList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FlagdSpec) DeepCopyInto(out *FlagdSpec) { + *out = *in + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } + in.Ingress.DeepCopyInto(&out.Ingress) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FlagdSpec. +func (in *FlagdSpec) DeepCopy() *FlagdSpec { + if in == nil { + return nil + } + out := new(FlagdSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FlagdStatus) DeepCopyInto(out *FlagdStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FlagdStatus. +func (in *FlagdStatus) DeepCopy() *FlagdStatus { + if in == nil { + return nil + } + out := new(FlagdStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Flags) DeepCopyInto(out *Flags) { *out = *in @@ -306,6 +402,45 @@ func (in *Flags) DeepCopy() *Flags { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IngressSpec) DeepCopyInto(out *IngressSpec) { + *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Hosts != nil { + in, out := &in.Hosts, &out.Hosts + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = make([]networkingv1.IngressTLS, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.IngressClassName != nil { + in, out := &in.IngressClassName, &out.IngressClassName + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressSpec. +func (in *IngressSpec) DeepCopy() *IngressSpec { + if in == nil { + return nil + } + out := new(IngressSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Source) DeepCopyInto(out *Source) { *out = *in diff --git a/chart/open-feature-operator/.gitignore b/chart/open-feature-operator/.gitignore index 05f3770e9..3b9b2639d 100755 --- a/chart/open-feature-operator/.gitignore +++ b/chart/open-feature-operator/.gitignore @@ -4,3 +4,4 @@ templates/crds/*.yaml # the following files are not generated, they are special cases !templates/namespace.yaml !templates/admissionregistration.k8s.io_v1_validatingwebhookconfiguration_open-feature-operator-validating-webhook-configuration.yaml +!templates/rbac.authorization.k8s.io_v1_clusterrole_open-feature-operator-manager-role.yaml diff --git a/chart/open-feature-operator/README.md b/chart/open-feature-operator/README.md index 5244d2152..b975df0b6 100644 --- a/chart/open-feature-operator/README.md +++ b/chart/open-feature-operator/README.md @@ -126,24 +126,37 @@ The command removes all the Kubernetes components associated with the chart and | `flagdProxyConfiguration.image.tag` | Sets the tag for the flagd-proxy deployment. | `v0.5.0` | | `flagdProxyConfiguration.debugLogging` | Controls the addition of the `--debug` flag to the container startup arguments. | `false` | +### Flagd configuration + +| Name | Description | Value | +| ------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------- | +| `flagdConfiguration.port` | Sets the port to expose the flagd API on. | `8013` | +| `flagdConfiguration.ofrepPort` | Sets the port to expose the ofrep API on. | `8016` | +| `flagdConfiguration.syncPort` | Sets the port to expose the sync API on. | `8015` | +| `flagdConfiguration.managementPort` | Sets the port to expose the management API on. | `8014` | +| `flagdConfiguration.image.repository` | Sets the image for the flagd deployment. | `ghcr.io/open-feature/flagd` | +| `flagdConfiguration.image.tag` | Sets the tag for the flagd deployment. | `v0.10.1` | +| `flagdConfiguration.debugLogging` | Controls the addition of the `--debug` flag to the container startup arguments. | `false` | + ### Operator resource configuration -| Name | Description | Value | -| ------------------------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------- | -| `controllerManager.kubeRbacProxy.image.repository` | Sets the image for the kube-rbac-proxy. | `gcr.io/kubebuilder/kube-rbac-proxy` | -| `controllerManager.kubeRbacProxy.image.tag` | Sets the version tag for the kube-rbac-proxy. | `v0.14.1` | -| `controllerManager.kubeRbacProxy.resources.limits.cpu` | Sets cpu resource limits for kube-rbac-proxy. | `500m` | -| `controllerManager.kubeRbacProxy.resources.limits.memory` | Sets memory resource limits for kube-rbac-proxy. | `128Mi` | -| `controllerManager.kubeRbacProxy.resources.requests.cpu` | Sets cpu resource requests for kube-rbac-proxy. | `5m` | -| `controllerManager.kubeRbacProxy.resources.requests.memory` | Sets memory resource requests for kube-rbac-proxy. | `64Mi` | -| `controllerManager.manager.image.repository` | Sets the image for the operator. | `ghcr.io/open-feature/open-feature-operator` | -| `controllerManager.manager.image.tag` | Sets the version tag for the operator. | `v0.5.4` | -| `controllerManager.manager.resources.limits.cpu` | Sets cpu resource limits for operator. | `500m` | -| `controllerManager.manager.resources.limits.memory` | Sets memory resource limits for operator. | `128Mi` | -| `controllerManager.manager.resources.requests.cpu` | Sets cpu resource requests for operator. | `10m` | -| `controllerManager.manager.resources.requests.memory` | Sets memory resource requests for operator. | `64Mi` | -| `controllerManager.replicas` | Sets number of replicas of the OpenFeature operator pod. | `1` | -| `managerConfig.flagsValidatonEnabled` | Enables the validating webhook for FeatureFlag CR. | `true` | -| `managerConfig.controllerManagerConfigYaml.health.healthProbeBindAddress` | Sets the bind address for health probes. | `:8081` | -| `managerConfig.controllerManagerConfigYaml.metrics.bindAddress` | Sets the bind address for metrics. | `127.0.0.1:8080` | -| `managerConfig.controllerManagerConfigYaml.webhook.port` | Sets the bind address for webhook. | `9443` | +| Name | Description | Value | +| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| `controllerManager.kubeRbacProxy.image.repository` | Sets the image for the kube-rbac-proxy. | `gcr.io/kubebuilder/kube-rbac-proxy` | +| `controllerManager.kubeRbacProxy.image.tag` | Sets the version tag for the kube-rbac-proxy. | `v0.14.1` | +| `controllerManager.kubeRbacProxy.resources.limits.cpu` | Sets cpu resource limits for kube-rbac-proxy. | `500m` | +| `controllerManager.kubeRbacProxy.resources.limits.memory` | Sets memory resource limits for kube-rbac-proxy. | `128Mi` | +| `controllerManager.kubeRbacProxy.resources.requests.cpu` | Sets cpu resource requests for kube-rbac-proxy. | `5m` | +| `controllerManager.kubeRbacProxy.resources.requests.memory` | Sets memory resource requests for kube-rbac-proxy. | `64Mi` | +| `controllerManager.manager.image.repository` | Sets the image for the operator. | `ghcr.io/open-feature/open-feature-operator` | +| `controllerManager.manager.image.tag` | Sets the version tag for the operator. | `v0.5.5` | +| `controllerManager.manager.resources.limits.cpu` | Sets cpu resource limits for operator. | `500m` | +| `controllerManager.manager.resources.limits.memory` | Sets memory resource limits for operator. | `128Mi` | +| `controllerManager.manager.resources.requests.cpu` | Sets cpu resource requests for operator. | `10m` | +| `controllerManager.manager.resources.requests.memory` | Sets memory resource requests for operator. | `64Mi` | +| `controllerManager.replicas` | Sets number of replicas of the OpenFeature operator pod. | `1` | +| `managerConfig.flagsValidatonEnabled` | Enables the validating webhook for FeatureFlag CR. | `true` | +| `managerConfig.flagdResourceEnabled` | Enables the controller for the Flagd CR and adds the required permissions to automatically manage the exposure of flagd via Service and Ingress resources. | `true` | +| `managerConfig.controllerManagerConfigYaml.health.healthProbeBindAddress` | Sets the bind address for health probes. | `:8081` | +| `managerConfig.controllerManagerConfigYaml.metrics.bindAddress` | Sets the bind address for metrics. | `127.0.0.1:8080` | +| `managerConfig.controllerManagerConfigYaml.webhook.port` | Sets the bind address for webhook. | `9443` | diff --git a/chart/open-feature-operator/templates/rbac.authorization.k8s.io_v1_clusterrole_open-feature-operator-manager-role.yaml b/chart/open-feature-operator/templates/rbac.authorization.k8s.io_v1_clusterrole_open-feature-operator-manager-role.yaml new file mode 100644 index 000000000..b56e223f2 --- /dev/null +++ b/chart/open-feature-operator/templates/rbac.authorization.k8s.io_v1_clusterrole_open-feature-operator-manager-role.yaml @@ -0,0 +1,139 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: open-feature-operator-manager-role +rules: + - apiGroups: + - "" + resources: + - configmaps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - "" + resources: + - pods + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - get + - list + - watch + - apiGroups: + - "" + resources: + - services + verbs: + - create + - get + - list + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - core.openfeature.dev + resources: + - featureflagsources + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - core.openfeature.dev + resources: + - featureflagsources/finalizers + verbs: + - get + - update + - apiGroups: + - core.openfeature.dev + resources: + - featureflagsources/status + verbs: + - get + - patch + - update + - apiGroups: + - core.openfeature.dev + resources: + - flagds + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - core.openfeature.dev + resources: + - flagds/finalizers + verbs: + - update +{{ if eq .Values.managerConfig.flagdResourceEnabled "true" }} + - apiGroups: + - "" + resources: + - services + - services/finalizers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +{{ end }} + - apiGroups: + - rbac.authorization.k8s.io + resourceNames: + - open-feature-operator-flagd-kubernetes-sync + resources: + - clusterrolebindings + verbs: + - get + - update diff --git a/chart/open-feature-operator/values.yaml b/chart/open-feature-operator/values.yaml index 6f0659db9..2d1d8be9b 100644 --- a/chart/open-feature-operator/values.yaml +++ b/chart/open-feature-operator/values.yaml @@ -50,6 +50,24 @@ flagdProxyConfiguration: ## @param flagdProxyConfiguration.debugLogging Controls the addition of the `--debug` flag to the container startup arguments. debugLogging: false +## @section Flagd configuration +flagdConfiguration: + ## @param flagdConfiguration.port Sets the port to expose the flagd API on. + port: 8013 + ## @param flagdConfiguration.ofrepPort Sets the port to expose the ofrep API on. + ofrepPort: 8016 + ## @param flagdConfiguration.syncPort Sets the port to expose the sync API on. + syncPort: 8015 + ## @param flagdConfiguration.managementPort Sets the port to expose the management API on. + managementPort: 8014 + image: + ## @param flagdConfiguration.image.repository Sets the image for the flagd deployment. + repository: "ghcr.io/open-feature/flagd" + ## @param flagdConfiguration.image.tag Sets the tag for the flagd deployment. + tag: v0.10.1 + ## @param flagdConfiguration.debugLogging Controls the addition of the `--debug` flag to the container startup arguments. + debugLogging: false + ## @section Operator resource configuration controllerManager: kubeRbacProxy: @@ -92,6 +110,8 @@ controllerManager: managerConfig: ## @param managerConfig.flagsValidatonEnabled Enables the validating webhook for FeatureFlag CR. flagsValidatonEnabled: "true" + ## @param managerConfig.flagdResourceEnabled Enables the controller for the Flagd CR and adds the required permissions to automatically manage the exposure of flagd via Service and Ingress resources. + flagdResourceEnabled: "true" controllerManagerConfigYaml: health: ## @param managerConfig.controllerManagerConfigYaml.health.healthProbeBindAddress Sets the bind address for health probes. diff --git a/common/common.go b/common/common.go index 42ec9d462..623beb54d 100644 --- a/common/common.go +++ b/common/common.go @@ -30,6 +30,9 @@ const ( ProbeInitialDelay = 5 FeatureFlagSourceAnnotation = "featureflagsource" EnabledAnnotation = "enabled" + ManagedByAnnotationKey = "app.kubernetes.io/managed-by" + ManagedByAnnotationValue = "open-feature-operator" + OperatorDeploymentName = "open-feature-operator-controller-manager" ) var ErrFlagdProxyNotReady = errors.New("flagd-proxy is not ready, deferring pod admission") @@ -77,3 +80,8 @@ func SharedOwnership(ownerReferences1, ownerReferences2 []metav1.OwnerReference) } return false } + +func IsManagedByOFO(obj client.Object) bool { + val, ok := obj.GetLabels()[ManagedByAnnotationKey] + return ok && val == ManagedByAnnotationValue +} diff --git a/common/flagdproxy/flagdproxy.go b/common/flagdproxy/flagdproxy.go index 056c61d65..f410b5c0b 100644 --- a/common/flagdproxy/flagdproxy.go +++ b/common/flagdproxy/flagdproxy.go @@ -6,6 +6,7 @@ import ( "reflect" "github.com/go-logr/logr" + "github.com/open-feature/open-feature-operator/common" "github.com/open-feature/open-feature-operator/common/types" appsV1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -16,11 +17,9 @@ import ( ) const ( - ManagedByAnnotationValue = "open-feature-operator" FlagdProxyDeploymentName = "flagd-proxy" FlagdProxyServiceAccountName = "open-feature-operator-flagd-proxy" FlagdProxyServiceName = "flagd-proxy-svc" - operatorDeploymentName = "open-feature-operator-controller-manager" ) type FlagdProxyHandler struct { @@ -46,7 +45,7 @@ func NewFlagdProxyConfiguration(env types.EnvConfig) *FlagdProxyConfiguration { Image: env.FlagdProxyImage, Tag: env.FlagdProxyTag, Namespace: env.PodNamespace, - OperatorDeploymentName: operatorDeploymentName, + OperatorDeploymentName: common.OperatorDeploymentName, Port: env.FlagdProxyPort, ManagementPort: env.FlagdProxyManagementPort, DebugLogging: env.FlagdProxyDebugLogging, @@ -120,8 +119,8 @@ func (f *FlagdProxyHandler) newFlagdProxyServiceManifest(ownerReference *metav1. }, Spec: corev1.ServiceSpec{ Selector: map[string]string{ - "app.kubernetes.io/name": FlagdProxyDeploymentName, - "app.kubernetes.io/managed-by": ManagedByAnnotationValue, + "app.kubernetes.io/name": FlagdProxyDeploymentName, + common.ManagedByAnnotationKey: common.ManagedByAnnotationValue, }, Ports: []corev1.ServicePort{ { @@ -150,7 +149,7 @@ func (f *FlagdProxyHandler) newFlagdProxyManifest(ownerReference *metav1.OwnerRe Namespace: f.config.Namespace, Labels: map[string]string{ "app": FlagdProxyDeploymentName, - "app.kubernetes.io/managed-by": ManagedByAnnotationValue, + "app.kubernetes.io/managed-by": common.ManagedByAnnotationValue, "app.kubernetes.io/version": f.config.Tag, }, OwnerReferences: []metav1.OwnerReference{*ownerReference}, @@ -167,7 +166,7 @@ func (f *FlagdProxyHandler) newFlagdProxyManifest(ownerReference *metav1.OwnerRe Labels: map[string]string{ "app": FlagdProxyDeploymentName, "app.kubernetes.io/name": FlagdProxyDeploymentName, - "app.kubernetes.io/managed-by": ManagedByAnnotationValue, + "app.kubernetes.io/managed-by": common.ManagedByAnnotationValue, "app.kubernetes.io/version": f.config.Tag, }, }, @@ -211,7 +210,7 @@ func (f *FlagdProxyHandler) doesFlagdProxyExist(ctx context.Context) (bool, *app } func (f *FlagdProxyHandler) shouldUpdateFlagdProxy(old, new *appsV1.Deployment) bool { - if !isDeployedByOFO(old) { + if !common.IsManagedByOFO(old) { f.Log.Info("flagd-proxy Deployment not managed by OFO") return false } @@ -241,8 +240,3 @@ func (f *FlagdProxyHandler) getOwnerReference(ctx context.Context) (*metav1.Owne Kind: operatorDeployment.Kind, }, nil } - -func isDeployedByOFO(d *appsV1.Deployment) bool { - val, ok := d.Labels["app.kubernetes.io/managed-by"] - return ok && val == ManagedByAnnotationValue -} diff --git a/common/flagdproxy/flagdproxy_test.go b/common/flagdproxy/flagdproxy_test.go index 1991a25a5..4a2fcc8e4 100644 --- a/common/flagdproxy/flagdproxy_test.go +++ b/common/flagdproxy/flagdproxy_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/go-logr/logr/testr" + "github.com/open-feature/open-feature-operator/common" "github.com/open-feature/open-feature-operator/common/types" "github.com/stretchr/testify/require" appsV1 "k8s.io/api/apps/v1" @@ -29,7 +30,7 @@ func TestNewFlagdProxyConfiguration(t *testing.T) { Port: 8015, ManagementPort: 8016, DebugLogging: false, - OperatorDeploymentName: operatorDeploymentName, + OperatorDeploymentName: common.OperatorDeploymentName, }, kpConfig) } @@ -53,7 +54,7 @@ func TestNewFlagdProxyConfiguration_OverrideEnvVars(t *testing.T) { Image: "my-image", Tag: "my-tag", Namespace: "my-namespace", - OperatorDeploymentName: operatorDeploymentName, + OperatorDeploymentName: common.OperatorDeploymentName, }, kpConfig) } @@ -137,7 +138,7 @@ func TestFlagdProxyHandler_HandleFlagdProxy_ProxyExistsWithBadVersion(t *testing Name: FlagdProxyDeploymentName, OwnerReferences: []metav1.OwnerReference{*ownerRef}, Labels: map[string]string{ - "app.kubernetes.io/managed-by": ManagedByAnnotationValue, + "app.kubernetes.io/managed-by": common.ManagedByAnnotationValue, }, }, Spec: v1.DeploymentSpec{ @@ -325,7 +326,7 @@ func TestFlagdProxyHandler_HandleFlagdProxy_CreateProxy(t *testing.T) { Namespace: "ns", Labels: map[string]string{ "app": FlagdProxyDeploymentName, - "app.kubernetes.io/managed-by": ManagedByAnnotationValue, + "app.kubernetes.io/managed-by": common.ManagedByAnnotationValue, "app.kubernetes.io/version": "tag", }, ResourceVersion: "1", @@ -333,7 +334,7 @@ func TestFlagdProxyHandler_HandleFlagdProxy_CreateProxy(t *testing.T) { { APIVersion: "apps/v1", Kind: "Deployment", - Name: operatorDeploymentName, + Name: common.OperatorDeploymentName, }, }, }, @@ -349,7 +350,7 @@ func TestFlagdProxyHandler_HandleFlagdProxy_CreateProxy(t *testing.T) { Labels: map[string]string{ "app": FlagdProxyDeploymentName, "app.kubernetes.io/name": FlagdProxyDeploymentName, - "app.kubernetes.io/managed-by": ManagedByAnnotationValue, + "app.kubernetes.io/managed-by": common.ManagedByAnnotationValue, "app.kubernetes.io/version": "tag", }, }, @@ -401,14 +402,14 @@ func TestFlagdProxyHandler_HandleFlagdProxy_CreateProxy(t *testing.T) { { APIVersion: "apps/v1", Kind: "Deployment", - Name: operatorDeploymentName, + Name: common.OperatorDeploymentName, }, }, }, Spec: corev1.ServiceSpec{ Selector: map[string]string{ "app.kubernetes.io/name": FlagdProxyDeploymentName, - "app.kubernetes.io/managed-by": ManagedByAnnotationValue, + "app.kubernetes.io/managed-by": common.ManagedByAnnotationValue, }, Ports: []corev1.ServicePort{ { @@ -427,14 +428,14 @@ func createOFOTestDeployment(ns string) *v1.Deployment { return &v1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Namespace: ns, - Name: operatorDeploymentName, + Name: common.OperatorDeploymentName, }, } } func getTestOFODeploymentOwnerRef(c client.Client, ns string) (*metav1.OwnerReference, error) { d := &appsV1.Deployment{} - if err := c.Get(context.TODO(), client.ObjectKey{Name: operatorDeploymentName, Namespace: ns}, d); err != nil { + if err := c.Get(context.TODO(), client.ObjectKey{Name: common.OperatorDeploymentName, Namespace: ns}, d); err != nil { return nil, err } return &metav1.OwnerReference{ diff --git a/common/types/envconfig.go b/common/types/envconfig.go index a4925e721..8b9c0c3dc 100644 --- a/common/types/envconfig.go +++ b/common/types/envconfig.go @@ -10,6 +10,15 @@ type EnvConfig struct { FlagdProxyManagementPort int `envconfig:"FLAGD_PROXY_MANAGEMENT_PORT" default:"8016"` FlagdProxyDebugLogging bool `envconfig:"FLAGD_PROXY_DEBUG_LOGGING" default:"false"` + FlagdImage string `envconfig:"FLAGD_IMAGE" default:"ghcr.io/open-feature/flagd"` + // renovate: datasource=github-tags depName=open-feature/flagd/flagd + FlagdTag string `envconfig:"FLAGD_TAG" default:"v0.10.1"` + FlagdPort int `envconfig:"FLAGD_PORT" default:"8013"` + FlagdOFREPPort int `envconfig:"FLAGD_OFREP_PORT" default:"8016"` + FlagdSyncPort int `envconfig:"FLAGD_SYNC_PORT" default:"8015"` + FlagdManagementPort int `envconfig:"FLAGD_MANAGEMENT_PORT" default:"8014"` + FlagdDebugLogging bool `envconfig:"FLAGD_DEBUG_LOGGING" default:"false"` + SidecarEnvVarPrefix string `envconfig:"SIDECAR_ENV_VAR_PREFIX" default:"FLAGD"` SidecarManagementPort int `envconfig:"SIDECAR_MANAGEMENT_PORT" default:"8014"` SidecarPort int `envconfig:"SIDECAR_PORT" default:"8013"` @@ -22,4 +31,5 @@ type EnvConfig struct { SidecarSyncProvider string `envconfig:"SIDECAR_SYNC_PROVIDER" default:"kubernetes"` SidecarLogFormat string `envconfig:"SIDECAR_LOG_FORMAT" default:"json"` SidecarProbesEnabled bool `envconfig:"SIDECAR_PROBES_ENABLED" default:"true"` + FlagdResourceEnabled bool `envconfig:"FLAGD_RESOURCE_ENABLED" default:"true"` } diff --git a/config/crd/bases/core.openfeature.dev_flagds.yaml b/config/crd/bases/core.openfeature.dev_flagds.yaml new file mode 100644 index 000000000..9cb1154aa --- /dev/null +++ b/config/crd/bases/core.openfeature.dev_flagds.yaml @@ -0,0 +1,143 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: flagds.core.openfeature.dev +spec: + group: core.openfeature.dev + names: + kind: Flagd + listKind: FlagdList + plural: flagds + singular: flagd + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: Flagd is the Schema for the flagds API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: FlagdSpec defines the desired state of Flagd + properties: + featureFlagSource: + description: |- + FeatureFlagSource references to a FeatureFlagSource from which the created flagd instance retrieves + the feature flag configurations + type: string + ingress: + description: Ingress + properties: + annotations: + additionalProperties: + type: string + description: Annotations the annotations to be added to the ingress + type: object + enabled: + description: Enabled enables/disables the ingress for flagd + type: boolean + flagdPath: + description: FlagdPath is the path to be used for accessing the + flagd flag evaluation API + type: string + hosts: + description: Hosts list of hosts to be added to the ingress + items: + type: string + type: array + ingressClassName: + description: IngressClassName defines the name if the ingress + class to be used for flagd + type: string + ofrepPath: + description: OFREPPath is the path to be used for accessing the + OFREP API + type: string + pathType: + description: PathType is the path type to be used for the ingress + rules + type: string + syncPath: + description: SyncPath is the path to be used for accessing the + sync API + type: string + tls: + description: TLS configuration for the ingress + items: + description: IngressTLS describes the transport layer security + associated with an Ingress. + properties: + hosts: + description: |- + Hosts are a list of hosts included in the TLS certificate. The values in + this list must match the name/s used in the tlsSecret. Defaults to the + wildcard host setting for the loadbalancer controller fulfilling this + Ingress, if left unspecified. + items: + type: string + type: array + x-kubernetes-list-type: atomic + secretName: + description: |- + SecretName is the name of the secret used to terminate TLS traffic on + port 443. Field is left optional to allow TLS routing based on SNI + hostname alone. If the SNI host in a listener conflicts with the "Host" + header field used by an IngressRule, the SNI host is used for termination + and value of the Host header is used for routing. + type: string + type: object + type: array + type: object + replicas: + default: 1 + description: |- + Replicas defines the number of replicas to create for the service. + Default: 1 + format: int32 + type: integer + serviceAccountName: + description: ServiceAccountName the service account name for the flagd + deployment + type: string + serviceType: + default: ClusterIP + description: |- + ServiceType represents the type of Service to create. + Must be one of: ClusterIP, NodePort, LoadBalancer, and ExternalName. + Default: ClusterIP + enum: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + type: string + required: + - featureFlagSource + type: object + status: + description: FlagdStatus defines the observed state of Flagd + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index b7902f7e0..877f4444c 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -4,6 +4,7 @@ resources: - bases/core.openfeature.dev_featureflags.yaml - bases/core.openfeature.dev_featureflagsources.yaml +- bases/core.openfeature.dev_flagds.yaml #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: @@ -11,12 +12,14 @@ patchesStrategicMerge: # patches here are for enabling the conversion webhook for each CRD #- patches/webhook_in_featureflags.yaml #- patches/webhook_in_featureflagsources.yaml +#- patches/webhook_in_flagds.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. # patches here are for enabling the CA injection for each CRD #- patches/cainjection_in_featureflags.yaml #- patches/cainjection_in_featureflagsources.yaml +#- patches/cainjection_in_flagds.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_core_flagds.yaml b/config/crd/patches/cainjection_in_core_flagds.yaml new file mode 100644 index 000000000..a1b2f05d1 --- /dev/null +++ b/config/crd/patches/cainjection_in_core_flagds.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: flagds.core.openfeature.dev diff --git a/config/crd/patches/webhook_in_core_flagds.yaml b/config/crd/patches/webhook_in_core_flagds.yaml new file mode 100644 index 000000000..3c992a318 --- /dev/null +++ b/config/crd/patches/webhook_in_core_flagds.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: flagds.core.openfeature.dev +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/overlays/helm/exclude-role.yaml b/config/overlays/helm/exclude-role.yaml new file mode 100644 index 000000000..03ac94b0a --- /dev/null +++ b/config/overlays/helm/exclude-role.yaml @@ -0,0 +1,5 @@ +$patch: delete +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: manager-role diff --git a/config/overlays/helm/kustomization.yaml b/config/overlays/helm/kustomization.yaml index 5b2441662..16f85e647 100644 --- a/config/overlays/helm/kustomization.yaml +++ b/config/overlays/helm/kustomization.yaml @@ -15,6 +15,7 @@ patchesStrategicMerge: - exclude-ns.yaml - manager.yaml - exclude-validatingwebhook.yaml + - exclude-role.yaml configMapGenerator: - name: manager-config diff --git a/config/overlays/helm/manager.yaml b/config/overlays/helm/manager.yaml index 9ff3b2a58..18eec907a 100644 --- a/config/overlays/helm/manager.yaml +++ b/config/overlays/helm/manager.yaml @@ -50,8 +50,24 @@ spec: value: "{{ .Values.flagdProxyConfiguration.managementPort }}" - name: FLAGD_PROXY_DEBUG_LOGGING value: "{{ .Values.flagdProxyConfiguration.debugLogging }}" + - name: FLAGD_IMAGE + value: "{{ .Values.flagdConfiguration.image.repository }}" + - name: FLAGD_TAG + value: "{{ .Values.flagdConfiguration.image.tag }}" + - name: FLAGD_PORT + value: "{{ .Values.flagdConfiguration.port }}" + - name: FLAGD_OFREP_PORT + value: "{{ .Values.flagdConfiguration.ofrepPort }}" + - name: FLAGD_SYNC_PORT + value: "{{ .Values.flagdConfiguration.syncPort }}" + - name: FLAGD_MANAGEMENT_PORT + value: "{{ .Values.flagdConfiguration.managementPort }}" + - name: FLAGD_DEBUG_LOGGING + value: "{{ .Values.flagdConfiguration.debugLogging }}" - name: FLAGS_VALIDATION_ENABLED value: "{{ .Values.managerConfig.flagsValidatonEnabled }}" + - name: FLAGD_RESOURCE_ENABLED + value: "{{ .Values.managerConfig.flagdResourceEnabled }}" - name: kube-rbac-proxy image: "{{ .Values.controllerManager.kubeRbacProxy.image.repository }}:{{ .Values.controllerManager.kubeRbacProxy.image.tag }}" resources: diff --git a/config/rbac/core_flagd_editor_role.yaml b/config/rbac/core_flagd_editor_role.yaml new file mode 100644 index 000000000..5e6329e89 --- /dev/null +++ b/config/rbac/core_flagd_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit flagds. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: flagd-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: open-feature-operator + app.kubernetes.io/part-of: open-feature-operator + app.kubernetes.io/managed-by: kustomize + name: flagd-editor-role +rules: +- apiGroups: + - core.openfeature.dev + resources: + - flagds + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - core.openfeature.dev + resources: + - flagds/status + verbs: + - get diff --git a/config/rbac/core_flagd_viewer_role.yaml b/config/rbac/core_flagd_viewer_role.yaml new file mode 100644 index 000000000..efa24e29a --- /dev/null +++ b/config/rbac/core_flagd_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view flagds. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: flagd-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: open-feature-operator + app.kubernetes.io/part-of: open-feature-operator + app.kubernetes.io/managed-by: kustomize + name: flagd-viewer-role +rules: +- apiGroups: + - core.openfeature.dev + resources: + - flagds + verbs: + - get + - list + - watch +- apiGroups: + - core.openfeature.dev + resources: + - flagds/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 3f811c918..d577ca5f0 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -56,6 +56,19 @@ rules: - patch - update - watch +- apiGroups: + - "" + resources: + - services + - services/finalizers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - core.openfeature.dev resources: @@ -73,6 +86,7 @@ rules: resources: - featureflagsources/finalizers verbs: + - get - update - apiGroups: - core.openfeature.dev @@ -82,6 +96,36 @@ rules: - get - patch - update +- apiGroups: + - core.openfeature.dev + resources: + - flagds + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - core.openfeature.dev + resources: + - flagds/finalizers + verbs: + - update +- apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - rbac.authorization.k8s.io resourceNames: diff --git a/config/samples/core_v1beta1_flagd.yaml b/config/samples/core_v1beta1_flagd.yaml new file mode 100644 index 000000000..92c1b3a02 --- /dev/null +++ b/config/samples/core_v1beta1_flagd.yaml @@ -0,0 +1,26 @@ +apiVersion: core.openfeature.dev/v1beta1 +kind: Flagd +metadata: + labels: + app.kubernetes.io/name: flagd + app.kubernetes.io/instance: flagd-sample + app.kubernetes.io/part-of: open-feature-operator + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: open-feature-operator + name: flagd-sample +spec: + replicas: 2 + serviceType: ClusterIP + serviceAccountName: default + featureFlagSource: end-to-end + ingress: + enabled: true + annotations: + nginx.ingress.kubernetes.io/force-ssl-redirect: "false" + hosts: + - flagd-sample + ingressClassName: nginx + pathType: ImplementationSpecific + flagdPath: /flagd(/|$)(.*) + ofrepPath: /ofrep(/|$)(.*) + syncPath: /sync(/|$)(.*) diff --git a/controllers/core/flagd/common/common.go b/controllers/core/flagd/common/common.go new file mode 100644 index 000000000..2ca793707 --- /dev/null +++ b/controllers/core/flagd/common/common.go @@ -0,0 +1,14 @@ +package resources + +type FlagdConfiguration struct { + FlagdPort int + OFREPPort int + SyncPort int + ManagementPort int + DebugLogging bool + Image string + Tag string + + OperatorNamespace string + OperatorDeploymentName string +} diff --git a/controllers/core/flagd/config.go b/controllers/core/flagd/config.go new file mode 100644 index 000000000..3b0548966 --- /dev/null +++ b/controllers/core/flagd/config.go @@ -0,0 +1,20 @@ +package flagd + +import ( + "github.com/open-feature/open-feature-operator/common" + "github.com/open-feature/open-feature-operator/common/types" + "github.com/open-feature/open-feature-operator/controllers/core/flagd/common" +) + +func NewFlagdConfiguration(env types.EnvConfig) resources.FlagdConfiguration { + return resources.FlagdConfiguration{ + Image: env.FlagdImage, + Tag: env.FlagdTag, + OperatorDeploymentName: common.OperatorDeploymentName, + FlagdPort: env.FlagdPort, + OFREPPort: env.FlagdOFREPPort, + SyncPort: env.FlagdSyncPort, + ManagementPort: env.FlagdManagementPort, + DebugLogging: env.FlagdDebugLogging, + } +} diff --git a/controllers/core/flagd/controller.go b/controllers/core/flagd/controller.go new file mode 100644 index 000000000..bc1a30f56 --- /dev/null +++ b/controllers/core/flagd/controller.go @@ -0,0 +1,118 @@ +/* +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 flagd + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + api "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + resources2 "github.com/open-feature/open-feature-operator/controllers/core/flagd/common" + "github.com/open-feature/open-feature-operator/controllers/core/flagd/resources" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// FlagdReconciler reconciles a Flagd object +type FlagdReconciler struct { + client.Client + Scheme *runtime.Scheme + Log logr.Logger + + FlagdConfig resources2.FlagdConfiguration + + ResourceReconciler IFlagdResourceReconciler + + FlagdDeployment resources.IFlagdResource + FlagdService resources.IFlagdResource + FlagdIngress resources.IFlagdResource +} + +type IFlagdResourceReconciler interface { + Reconcile(ctx context.Context, flagd *api.Flagd, obj client.Object, resource resources.IFlagdResource) error +} + +//+kubebuilder:rbac:groups=core.openfeature.dev,resources=flagds,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core.openfeature.dev,resources=flagds/finalizers,verbs=update +//+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core,resources=services;services/finalizers,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core.openfeature.dev,resources=featureflagsources/finalizers,verbs=get + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.14.4/pkg/reconcile +func (r *FlagdReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + r.Log.Info("Searching for FeatureFlagSource") + + // Fetch the Flagd resource + flagd := &api.Flagd{} + if err := r.Client.Get(ctx, req.NamespacedName, flagd); err != nil { + if errors.IsNotFound(err) { + // taking down all associated K8s resources is handled by K8s + r.Log.Info(fmt.Sprintf("Flagd resource '%s' not found. Ignoring since object must be deleted", req.NamespacedName)) + return ctrl.Result{Requeue: false}, nil + } + r.Log.Error(err, fmt.Sprintf("Failed to get Flagd resource '%s'", req.NamespacedName)) + return ctrl.Result{}, err + } + + if err := r.ResourceReconciler.Reconcile( + ctx, + flagd, + &appsv1.Deployment{}, + r.FlagdDeployment, + ); err != nil { + return ctrl.Result{}, err + } + + if err := r.ResourceReconciler.Reconcile( + ctx, + flagd, + &v1.Service{}, + r.FlagdService, + ); err != nil { + return ctrl.Result{}, err + } + + if flagd.Spec.Ingress.Enabled { + if err := r.ResourceReconciler.Reconcile( + ctx, + flagd, + &networkingv1.Ingress{}, + r.FlagdIngress, + ); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *FlagdReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&api.Flagd{}). + Complete(r) +} diff --git a/controllers/core/flagd/controller_test.go b/controllers/core/flagd/controller_test.go new file mode 100644 index 000000000..cf81fb8ef --- /dev/null +++ b/controllers/core/flagd/controller_test.go @@ -0,0 +1,349 @@ +package flagd + +import ( + "context" + "errors" + "fmt" + "reflect" + "testing" + + "github.com/golang/mock/gomock" + api "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + "github.com/open-feature/open-feature-operator/controllers/core/flagd/common" + commonmock "github.com/open-feature/open-feature-operator/controllers/core/flagd/mock" + resourcemock "github.com/open-feature/open-feature-operator/controllers/core/flagd/resources/mock" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + controllerruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var testFlagdConfig = resources.FlagdConfiguration{ + FlagdPort: 8013, + OFREPPort: 8016, + ManagementPort: 8014, + DebugLogging: false, + Image: "flagd", + Tag: "latest", + OperatorNamespace: "ofo-system", + OperatorDeploymentName: "ofo", +} + +type flagdMatcher struct { + flagdObj api.Flagd +} + +func (fm flagdMatcher) Matches(x interface{}) bool { + flagd, ok := x.(*api.Flagd) + if !ok { + return false + } + return reflect.DeepEqual(fm.flagdObj.ObjectMeta, flagd.ObjectMeta) && reflect.DeepEqual(fm.flagdObj.Spec, flagd.Spec) +} + +// String describes what the matcher matches. +func (fm flagdMatcher) String() string { + return fmt.Sprintf("%v", fm.flagdObj) +} + +func TestFlagdReconciler_ReconcileWithIngress(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + Spec: api.FlagdSpec{ + Ingress: api.IngressSpec{Enabled: true}, + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(flagdObj).Build() + + ctrl := gomock.NewController(t) + + deploymentResource := resourcemock.NewMockIFlagdResource(ctrl) + serviceResource := resourcemock.NewMockIFlagdResource(ctrl) + ingressResource := resourcemock.NewMockIFlagdResource(ctrl) + + resourceReconciler := commonmock.NewMockIFlagdResourceReconciler(ctrl) + + resourceReconciler.EXPECT(). + Reconcile( + gomock.Any(), + flagdMatcher{flagdObj: *flagdObj}, + gomock.AssignableToTypeOf(&appsv1.Deployment{}), + deploymentResource, + ).Times(1).Return(nil) + + resourceReconciler.EXPECT(). + Reconcile( + gomock.Any(), + flagdMatcher{flagdObj: *flagdObj}, + gomock.AssignableToTypeOf(&v1.Service{}), + serviceResource, + ).Times(1).Return(nil) + + resourceReconciler.EXPECT(). + Reconcile( + gomock.Any(), + flagdMatcher{flagdObj: *flagdObj}, + gomock.AssignableToTypeOf(&networkingv1.Ingress{}), + ingressResource, + ).Times(1).Return(nil) + + r := setupReconciler(fakeClient, deploymentResource, serviceResource, ingressResource, resourceReconciler) + + result, err := r.Reconcile(context.Background(), controllerruntime.Request{ + NamespacedName: types.NamespacedName{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + }, + }) + + require.Nil(t, err) + require.Equal(t, controllerruntime.Result{}, result) +} + +func TestFlagdReconciler_ReconcileWithoutIngress(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + Spec: api.FlagdSpec{}, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(flagdObj).Build() + + ctrl := gomock.NewController(t) + + deploymentResource := resourcemock.NewMockIFlagdResource(ctrl) + serviceResource := resourcemock.NewMockIFlagdResource(ctrl) + ingressResource := resourcemock.NewMockIFlagdResource(ctrl) + + resourceReconciler := commonmock.NewMockIFlagdResourceReconciler(ctrl) + + resourceReconciler.EXPECT(). + Reconcile( + gomock.Any(), + flagdMatcher{flagdObj: *flagdObj}, + gomock.AssignableToTypeOf(&appsv1.Deployment{}), + deploymentResource, + ).Times(1).Return(nil) + + resourceReconciler.EXPECT(). + Reconcile( + gomock.Any(), + flagdMatcher{flagdObj: *flagdObj}, + gomock.AssignableToTypeOf(&v1.Service{}), + serviceResource, + ).Times(1).Return(nil) + + r := setupReconciler(fakeClient, deploymentResource, serviceResource, ingressResource, resourceReconciler) + + result, err := r.Reconcile(context.Background(), controllerruntime.Request{ + NamespacedName: types.NamespacedName{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + }, + }) + + require.Nil(t, err) + require.Equal(t, controllerruntime.Result{}, result) +} + +func TestFlagdReconciler_ReconcileResourceNotFound(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects().Build() + + r := setupReconciler(fakeClient, nil, nil, nil, nil) + + result, err := r.Reconcile(context.Background(), controllerruntime.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "my-namespace", + Name: "my-flagd", + }, + }) + + require.Nil(t, err) + require.Equal(t, controllerruntime.Result{}, result) +} + +func TestFlagdReconciler_ReconcileFailDeployment(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + Spec: api.FlagdSpec{}, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(flagdObj).Build() + + ctrl := gomock.NewController(t) + + deploymentResource := resourcemock.NewMockIFlagdResource(ctrl) + + resourceReconciler := commonmock.NewMockIFlagdResourceReconciler(ctrl) + + resourceReconciler.EXPECT(). + Reconcile( + gomock.Any(), + flagdMatcher{flagdObj: *flagdObj}, + gomock.AssignableToTypeOf(&appsv1.Deployment{}), + deploymentResource, + ).Times(1).Return(errors.New("oops")) + + r := setupReconciler(fakeClient, deploymentResource, nil, nil, resourceReconciler) + + result, err := r.Reconcile(context.Background(), controllerruntime.Request{ + NamespacedName: types.NamespacedName{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + }, + }) + + require.NotNil(t, err) + require.Equal(t, controllerruntime.Result{}, result) +} + +func TestFlagdReconciler_ReconcileFailService(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + Spec: api.FlagdSpec{}, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(flagdObj).Build() + + ctrl := gomock.NewController(t) + + deploymentResource := resourcemock.NewMockIFlagdResource(ctrl) + serviceResource := resourcemock.NewMockIFlagdResource(ctrl) + + resourceReconciler := commonmock.NewMockIFlagdResourceReconciler(ctrl) + + resourceReconciler.EXPECT(). + Reconcile( + gomock.Any(), + flagdMatcher{flagdObj: *flagdObj}, + gomock.AssignableToTypeOf(&appsv1.Deployment{}), + deploymentResource, + ).Times(1).Return(nil) + + resourceReconciler.EXPECT(). + Reconcile( + gomock.Any(), + flagdMatcher{flagdObj: *flagdObj}, + gomock.AssignableToTypeOf(&v1.Service{}), + serviceResource, + ).Times(1).Return(errors.New("oops")) + + r := setupReconciler(fakeClient, deploymentResource, serviceResource, nil, resourceReconciler) + + result, err := r.Reconcile(context.Background(), controllerruntime.Request{ + NamespacedName: types.NamespacedName{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + }, + }) + + require.NotNil(t, err) + require.Equal(t, controllerruntime.Result{}, result) +} + +func TestFlagdReconciler_ReconcileFailIngress(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + Spec: api.FlagdSpec{ + Ingress: api.IngressSpec{Enabled: true}, + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(flagdObj).Build() + + ctrl := gomock.NewController(t) + + deploymentResource := resourcemock.NewMockIFlagdResource(ctrl) + serviceResource := resourcemock.NewMockIFlagdResource(ctrl) + ingressResource := resourcemock.NewMockIFlagdResource(ctrl) + + resourceReconciler := commonmock.NewMockIFlagdResourceReconciler(ctrl) + + resourceReconciler.EXPECT(). + Reconcile( + gomock.Any(), + flagdMatcher{flagdObj: *flagdObj}, + gomock.AssignableToTypeOf(&appsv1.Deployment{}), + deploymentResource, + ).Times(1).Return(nil) + + resourceReconciler.EXPECT(). + Reconcile( + gomock.Any(), + flagdMatcher{flagdObj: *flagdObj}, + gomock.AssignableToTypeOf(&v1.Service{}), + serviceResource, + ).Times(1).Return(nil) + + resourceReconciler.EXPECT(). + Reconcile( + gomock.Any(), + flagdMatcher{flagdObj: *flagdObj}, + gomock.AssignableToTypeOf(&networkingv1.Ingress{}), + ingressResource, + ).Times(1).Return(errors.New("oops")) + + r := setupReconciler(fakeClient, deploymentResource, serviceResource, ingressResource, resourceReconciler) + + result, err := r.Reconcile(context.Background(), controllerruntime.Request{ + NamespacedName: types.NamespacedName{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + }, + }) + + require.NotNil(t, err) + require.Equal(t, controllerruntime.Result{}, result) +} + +func setupReconciler(fakeClient client.WithWatch, deploymentReconciler, serviceReconciler, ingressReconciler *resourcemock.MockIFlagdResource, resourceReconciler *commonmock.MockIFlagdResourceReconciler) *FlagdReconciler { + return &FlagdReconciler{ + Client: fakeClient, + Scheme: fakeClient.Scheme(), + Log: controllerruntime.Log.WithName("flagd controller"), + FlagdConfig: testFlagdConfig, + FlagdDeployment: deploymentReconciler, + FlagdService: serviceReconciler, + FlagdIngress: ingressReconciler, + ResourceReconciler: resourceReconciler, + } +} diff --git a/controllers/core/flagd/mock/mock.go b/controllers/core/flagd/mock/mock.go new file mode 100644 index 000000000..0df09e3d6 --- /dev/null +++ b/controllers/core/flagd/mock/mock.go @@ -0,0 +1,52 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./controllers/core/flagd/controller.go + +// Package commonmock is a generated GoMock package. +package commonmock + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1beta1 "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + resources "github.com/open-feature/open-feature-operator/controllers/core/flagd/resources" + client "sigs.k8s.io/controller-runtime/pkg/client" +) + +// MockIFlagdResourceReconciler is a mock of IFlagdResourceReconciler interface. +type MockIFlagdResourceReconciler struct { + ctrl *gomock.Controller + recorder *MockIFlagdResourceReconcilerMockRecorder +} + +// MockIFlagdResourceReconcilerMockRecorder is the mock recorder for MockIFlagdResourceReconciler. +type MockIFlagdResourceReconcilerMockRecorder struct { + mock *MockIFlagdResourceReconciler +} + +// NewMockIFlagdResourceReconciler creates a new mock instance. +func NewMockIFlagdResourceReconciler(ctrl *gomock.Controller) *MockIFlagdResourceReconciler { + mock := &MockIFlagdResourceReconciler{ctrl: ctrl} + mock.recorder = &MockIFlagdResourceReconcilerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIFlagdResourceReconciler) EXPECT() *MockIFlagdResourceReconcilerMockRecorder { + return m.recorder +} + +// Reconcile mocks base method. +func (m *MockIFlagdResourceReconciler) Reconcile(ctx context.Context, flagd *v1beta1.Flagd, obj client.Object, resource resources.IFlagdResource) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Reconcile", ctx, flagd, obj, resource) + ret0, _ := ret[0].(error) + return ret0 +} + +// Reconcile indicates an expected call of Reconcile. +func (mr *MockIFlagdResourceReconcilerMockRecorder) Reconcile(ctx, flagd, obj, resource interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Reconcile", reflect.TypeOf((*MockIFlagdResourceReconciler)(nil).Reconcile), ctx, flagd, obj, resource) +} diff --git a/controllers/core/flagd/resource_reconciler.go b/controllers/core/flagd/resource_reconciler.go new file mode 100644 index 000000000..fcb11c938 --- /dev/null +++ b/controllers/core/flagd/resource_reconciler.go @@ -0,0 +1,74 @@ +package flagd + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + api "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + "github.com/open-feature/open-feature-operator/common" + "github.com/open-feature/open-feature-operator/controllers/core/flagd/resources" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type ResourceReconciler struct { + client.Client + Scheme *runtime.Scheme + Log logr.Logger +} + +func (r *ResourceReconciler) Reconcile(ctx context.Context, flagd *api.Flagd, obj client.Object, resource resources.IFlagdResource) error { + exists := false + existingObj := obj + err := r.Client.Get(ctx, client.ObjectKey{ + Namespace: flagd.Namespace, + Name: flagd.Name, + }, existingObj) + + if err == nil { + exists = true + } else if err != nil && !errors.IsNotFound(err) { + r.Log.Error(err, fmt.Sprintf("Failed to get flagd %s '%s/%s'", obj.GetObjectKind(), flagd.Namespace, flagd.Name)) + return err + } + + // check if the resource is managed by the operator. + // if not, do not continue to not mess with anything user generated + if exists && !common.IsManagedByOFO(existingObj) { + r.Log.Info(fmt.Sprintf("Found existing %s '%s/%s' that is not managed by OFO. Will not proceed.", obj.GetObjectKind(), flagd.Namespace, flagd.Name)) + return fmt.Errorf("resource already exists and is not managed by OFO") + } + + newObj, err := resource.GetResource(ctx, flagd) + if err != nil { + r.Log.Error(err, fmt.Sprintf("Could not create new flagd %s resource '%s/%s'", obj.GetObjectKind(), flagd.Namespace, flagd.Name)) + return err + } + + if !exists { + return r.createResource(ctx, flagd, obj, newObj) + } else if !resource.AreObjectsEqual(existingObj, newObj) { + return r.updateResource(ctx, flagd, obj, newObj) + } + return nil +} + +func (r *ResourceReconciler) createResource(ctx context.Context, flagd *api.Flagd, obj client.Object, newObj client.Object) error { + r.Log.Info(fmt.Sprintf("Creating %v", newObj)) + if err := r.Client.Create(ctx, newObj); err != nil { + r.Log.Error(err, fmt.Sprintf("Failed to create Flagd %s '%s/%s'", obj.GetObjectKind(), flagd.Namespace, flagd.Name)) + return err + } + return nil +} + +func (r *ResourceReconciler) updateResource(ctx context.Context, flagd *api.Flagd, obj client.Object, newObj client.Object) error { + r.Log.Info(fmt.Sprintf("Updating %v", newObj)) + if err := r.Client.Update(ctx, newObj); err != nil { + r.Log.Error(err, fmt.Sprintf("Failed to update Flagd %s '%s/%s'", obj.GetObjectKind(), flagd.Namespace, flagd.Name)) + return err + } + return nil +} diff --git a/controllers/core/flagd/resource_reconciler_test.go b/controllers/core/flagd/resource_reconciler_test.go new file mode 100644 index 000000000..964fe614b --- /dev/null +++ b/controllers/core/flagd/resource_reconciler_test.go @@ -0,0 +1,217 @@ +package flagd + +import ( + "context" + "errors" + "testing" + + "github.com/golang/mock/gomock" + api "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + "github.com/open-feature/open-feature-operator/common" + resourcemock "github.com/open-feature/open-feature-operator/controllers/core/flagd/resources/mock" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + controllerruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestResourceReconciler_Reconcile_CreateResource(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() + + r := &ResourceReconciler{ + Client: fakeClient, + Scheme: fakeClient.Scheme(), + Log: controllerruntime.Log.WithName("resource-reconciler"), + } + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + } + + ctrl := gomock.NewController(t) + mockRes := resourcemock.NewMockIFlagdResource(ctrl) + mockRes.EXPECT(). + GetResource(gomock.Any(), flagdMatcher{flagdObj: *flagdObj}). + Times(1). + Return(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + }, + Data: map[string]string{}, + }, nil) + + err = r.Reconcile( + context.Background(), + flagdObj, + &corev1.ConfigMap{}, + mockRes, + ) + + require.Nil(t, err) + + result := &corev1.ConfigMap{} + err = fakeClient.Get(context.Background(), client.ObjectKey{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + }, result) + + require.Nil(t, err) +} + +func TestResourceReconciler_Reconcile_UpdateManagedResource(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + Labels: map[string]string{ + common.ManagedByAnnotationKey: common.ManagedByAnnotationValue, + }, + }, + Data: map[string]string{}, + }).Build() + + r := &ResourceReconciler{ + Client: fakeClient, + Scheme: fakeClient.Scheme(), + Log: controllerruntime.Log.WithName("resource-reconciler"), + } + + ctrl := gomock.NewController(t) + mockRes := resourcemock.NewMockIFlagdResource(ctrl) + mockRes.EXPECT(). + GetResource(gomock.Any(), flagdMatcher{flagdObj: *flagdObj}). + Times(1). + Return(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + }, + Data: map[string]string{ + "foo": "bar", + }, + }, nil) + + mockRes.EXPECT().AreObjectsEqual(gomock.Any(), gomock.Any()).Return(false) + + err = r.Reconcile( + context.Background(), + flagdObj, + &corev1.ConfigMap{}, + mockRes, + ) + + require.Nil(t, err) + + result := &corev1.ConfigMap{} + err = fakeClient.Get(context.Background(), client.ObjectKey{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + }, result) + + require.Nil(t, err) + + // verify the resource was updated + require.Equal(t, "bar", result.Data["foo"]) +} + +func TestResourceReconciler_Reconcile_CannotCreateResource(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + Labels: map[string]string{ + common.ManagedByAnnotationKey: common.ManagedByAnnotationValue, + }, + }, + Data: map[string]string{}, + }).Build() + + r := &ResourceReconciler{ + Client: fakeClient, + Scheme: fakeClient.Scheme(), + Log: controllerruntime.Log.WithName("resource-reconciler"), + } + + ctrl := gomock.NewController(t) + mockRes := resourcemock.NewMockIFlagdResource(ctrl) + mockRes.EXPECT(). + GetResource(gomock.Any(), flagdMatcher{flagdObj: *flagdObj}). + Times(1). + Return(nil, errors.New("oops")) + + err = r.Reconcile( + context.Background(), + flagdObj, + &corev1.ConfigMap{}, + mockRes, + ) + + require.NotNil(t, err) +} + +func TestResourceReconciler_Reconcile_UnmanagedResourceAlreadyExists(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: flagdObj.Namespace, + Name: flagdObj.Name, + }, + Data: map[string]string{}, + }).Build() + + r := &ResourceReconciler{ + Client: fakeClient, + Scheme: fakeClient.Scheme(), + Log: controllerruntime.Log.WithName("resource-reconciler"), + } + ctrl := gomock.NewController(t) + mockRes := resourcemock.NewMockIFlagdResource(ctrl) + + err = r.Reconcile( + context.Background(), + flagdObj, + &corev1.ConfigMap{}, + mockRes, + ) + + require.NotNil(t, err) +} diff --git a/controllers/core/flagd/resources/deployment.go b/controllers/core/flagd/resources/deployment.go new file mode 100644 index 000000000..0cedc3bc2 --- /dev/null +++ b/controllers/core/flagd/resources/deployment.go @@ -0,0 +1,117 @@ +package resources + +import ( + "context" + "errors" + "fmt" + "reflect" + + "github.com/go-logr/logr" + api "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + "github.com/open-feature/open-feature-operator/common" + "github.com/open-feature/open-feature-operator/common/flagdinjector" + "github.com/open-feature/open-feature-operator/controllers/core/flagd/common" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type FlagdDeployment struct { + client.Client + Log logr.Logger + + FlagdInjector flagdinjector.IFlagdContainerInjector + FlagdConfig resources.FlagdConfiguration +} + +func (r *FlagdDeployment) AreObjectsEqual(o1 client.Object, o2 client.Object) bool { + oldDeployment, ok := o1.(*appsv1.Deployment) + if !ok { + return false + } + + newDeployment, ok := o2.(*appsv1.Deployment) + if !ok { + return false + } + + return reflect.DeepEqual(oldDeployment.Spec, newDeployment.Spec) +} + +func (r *FlagdDeployment) GetResource(ctx context.Context, flagd *api.Flagd) (client.Object, error) { + labels := map[string]string{ + "app": flagd.Name, + "app.kubernetes.io/name": flagd.Name, + "app.kubernetes.io/managed-by": common.ManagedByAnnotationValue, + "app.kubernetes.io/version": r.FlagdConfig.Tag, + } + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: flagd.Name, + Namespace: flagd.Namespace, + Labels: labels, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: flagd.APIVersion, + Kind: flagd.Kind, + Name: flagd.Name, + UID: flagd.UID, + }}, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: flagd.Spec.Replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": flagd.Name, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: flagd.Spec.ServiceAccountName, + }, + }, + }, + } + + featureFlagSource := &api.FeatureFlagSource{} + + if err := r.Client.Get(ctx, client.ObjectKey{ + Namespace: flagd.Namespace, + Name: flagd.Spec.FeatureFlagSource, + }, featureFlagSource); err != nil { + return nil, fmt.Errorf("could not look up feature flag source for flagd: %w", err) + } + + err := r.FlagdInjector.InjectFlagd(ctx, &deployment.ObjectMeta, &deployment.Spec.Template.Spec, &featureFlagSource.Spec) + if err != nil { + return nil, fmt.Errorf("could not inject flagd container into deployment: %w", err) + } + + if len(deployment.Spec.Template.Spec.Containers) == 0 { + return nil, errors.New("no flagd container has been injected into deployment") + } + + deployment.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{ + { + Name: "management", + ContainerPort: int32(r.FlagdConfig.ManagementPort), + }, + { + Name: "flagd", + ContainerPort: int32(r.FlagdConfig.FlagdPort), + }, + { + Name: "ofrep", + ContainerPort: int32(r.FlagdConfig.OFREPPort), + }, + { + Name: "sync", + ContainerPort: int32(r.FlagdConfig.SyncPort), + }, + } + + return deployment, nil +} diff --git a/controllers/core/flagd/resources/deployment_test.go b/controllers/core/flagd/resources/deployment_test.go new file mode 100644 index 000000000..d886081ab --- /dev/null +++ b/controllers/core/flagd/resources/deployment_test.go @@ -0,0 +1,316 @@ +// nolint:dupl +package resources + +import ( + "context" + "errors" + "testing" + + "github.com/golang/mock/gomock" + api "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + commonfake "github.com/open-feature/open-feature-operator/common/flagdinjector/fake" + resources "github.com/open-feature/open-feature-operator/controllers/core/flagd/common" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + controllerruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var testFlagdConfig = resources.FlagdConfiguration{ + FlagdPort: 8013, + OFREPPort: 8016, + ManagementPort: 8014, + DebugLogging: false, + Image: "flagd", + Tag: "latest", + OperatorNamespace: "ofo-system", + OperatorDeploymentName: "ofo", +} + +func TestFlagdDeployment_getFlagdDeployment(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + Spec: api.FlagdSpec{ + FeatureFlagSource: "my-flag-source", + }, + } + + flagSource := &api.FeatureFlagSource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flag-source", + Namespace: "my-namespace", + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(flagSource, flagdObj).Build() + + ctrl := gomock.NewController(t) + + fakeFlagdInjector := commonfake.NewMockFlagdContainerInjector(ctrl) + fakeFlagdInjector.EXPECT(). + InjectFlagd(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + DoAndReturn(func( + ctx context.Context, + objectMeta *metav1.ObjectMeta, + podSpec *v1.PodSpec, + flagSourceConfig *api.FeatureFlagSourceSpec, + ) error { + // simulate the injection of a container into the podspec + podSpec.Containers = []v1.Container{ + { + Name: "flagd", + }, + } + return nil + }) + + r := &FlagdDeployment{ + Client: fakeClient, + Log: controllerruntime.Log.WithName("test"), + FlagdInjector: fakeFlagdInjector, + FlagdConfig: testFlagdConfig, + } + + res, err := r.GetResource(context.Background(), flagdObj) + + require.Nil(t, err) + require.NotNil(t, res) + + deploymentResult := res.(*appsv1.Deployment) + + require.Equal(t, flagdObj.Name, deploymentResult.Name) + require.Equal(t, flagdObj.Namespace, deploymentResult.Namespace) + require.Len(t, deploymentResult.OwnerReferences, 1) + require.Equal(t, []v1.ContainerPort{ + { + Name: "management", + ContainerPort: int32(r.FlagdConfig.ManagementPort), + }, + { + Name: "flagd", + ContainerPort: int32(r.FlagdConfig.FlagdPort), + }, + { + Name: "ofrep", + ContainerPort: int32(r.FlagdConfig.OFREPPort), + }, + { + Name: "sync", + ContainerPort: int32(r.FlagdConfig.SyncPort), + }, + }, deploymentResult.Spec.Template.Spec.Containers[0].Ports) +} + +func TestFlagdDeployment_getFlagdDeployment_ErrorInInjector(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + Spec: api.FlagdSpec{ + FeatureFlagSource: "my-flag-source", + }, + } + + flagSource := &api.FeatureFlagSource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flag-source", + Namespace: "my-namespace", + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(flagSource, flagdObj).Build() + + ctrl := gomock.NewController(t) + + fakeFlagdInjector := commonfake.NewMockFlagdContainerInjector(ctrl) + fakeFlagdInjector.EXPECT(). + InjectFlagd(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(errors.New("oops")) + + r := &FlagdDeployment{ + Client: fakeClient, + Log: controllerruntime.Log.WithName("test"), + FlagdInjector: fakeFlagdInjector, + FlagdConfig: testFlagdConfig, + } + + deploymentResult, err := r.GetResource(context.Background(), flagdObj) + + require.NotNil(t, err) + require.Nil(t, deploymentResult) +} + +func TestFlagdDeployment_getFlagdDeployment_ContainerNotInjected(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + Spec: api.FlagdSpec{ + FeatureFlagSource: "my-flag-source", + }, + } + + flagSource := &api.FeatureFlagSource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flag-source", + Namespace: "my-namespace", + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(flagSource, flagdObj).Build() + + ctrl := gomock.NewController(t) + + fakeFlagdInjector := commonfake.NewMockFlagdContainerInjector(ctrl) + fakeFlagdInjector.EXPECT(). + InjectFlagd(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(nil) + + r := &FlagdDeployment{ + Client: fakeClient, + Log: controllerruntime.Log.WithName("test"), + FlagdInjector: fakeFlagdInjector, + FlagdConfig: testFlagdConfig, + } + + deploymentResult, err := r.GetResource(context.Background(), flagdObj) + + require.NotNil(t, err) + require.Nil(t, deploymentResult) +} + +func TestFlagdDeployment_getFlagdDeployment_FlagSourceNotFound(t *testing.T) { + err := api.AddToScheme(scheme.Scheme) + require.Nil(t, err) + + flagdObj := &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + Spec: api.FlagdSpec{ + FeatureFlagSource: "my-flag-source", + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(flagdObj).Build() + + ctrl := gomock.NewController(t) + + fakeFlagdInjector := commonfake.NewMockFlagdContainerInjector(ctrl) + + r := &FlagdDeployment{ + Client: fakeClient, + Log: controllerruntime.Log.WithName("test"), + FlagdInjector: fakeFlagdInjector, + FlagdConfig: testFlagdConfig, + } + + deploymentResult, err := r.GetResource(context.Background(), flagdObj) + + require.NotNil(t, err) + require.Nil(t, deploymentResult) +} + +func Test_areDeploymentsEqual(t *testing.T) { + type args struct { + old client.Object + new client.Object + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "has changed", + args: args{ + old: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: intPtr(1), + }, + }, + new: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: intPtr(2), + }, + }, + }, + want: false, + }, + { + name: "has not changed", + args: args{ + old: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: intPtr(1), + }, + }, + new: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: intPtr(1), + }, + }, + }, + want: true, + }, + { + name: "old is not a deployment", + args: args{ + old: &v1.ConfigMap{}, + new: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: intPtr(1), + }, + }, + }, + want: false, + }, + { + name: "new is not a deployment", + args: args{ + old: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: intPtr(1), + }, + }, + new: &v1.ConfigMap{}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + d := &FlagdDeployment{} + got := d.AreObjectsEqual(tt.args.old, tt.args.new) + + require.Equal(t, tt.want, got) + }) + } +} + +func intPtr(i int32) *int32 { + return &i +} diff --git a/controllers/core/flagd/resources/ingress.go b/controllers/core/flagd/resources/ingress.go new file mode 100644 index 000000000..06984411f --- /dev/null +++ b/controllers/core/flagd/resources/ingress.go @@ -0,0 +1,151 @@ +package resources + +import ( + "context" + "reflect" + + api "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + "github.com/open-feature/open-feature-operator/common" + "github.com/open-feature/open-feature-operator/controllers/core/flagd/common" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + defaultFlagdPath = "/flagd" + defaultOFREPPath = "/ofrep" + defaultSyncPath = "/sync" +) + +type FlagdIngress struct { + FlagdConfig resources.FlagdConfiguration +} + +func (r FlagdIngress) AreObjectsEqual(o1 client.Object, o2 client.Object) bool { + oldIngress, ok := o1.(*networkingv1.Ingress) + if !ok { + return false + } + + newIngress, ok := o2.(*networkingv1.Ingress) + if !ok { + return false + } + + return reflect.DeepEqual(oldIngress.Spec, newIngress.Spec) +} + +func (r FlagdIngress) GetResource(_ context.Context, flagd *api.Flagd) (client.Object, error) { + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: flagd.Name, + Namespace: flagd.Namespace, + Labels: map[string]string{ + "app": flagd.Name, + "app.kubernetes.io/name": flagd.Name, + "app.kubernetes.io/managed-by": common.ManagedByAnnotationValue, + "app.kubernetes.io/version": r.FlagdConfig.Tag, + }, + Annotations: flagd.Spec.Ingress.Annotations, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: flagd.APIVersion, + Kind: flagd.Kind, + Name: flagd.Name, + UID: flagd.UID, + }}, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: flagd.Spec.Ingress.IngressClassName, + TLS: flagd.Spec.Ingress.TLS, + Rules: r.getRules(flagd), + }, + }, nil +} + +func (r FlagdIngress) getRules(flagd *api.Flagd) []networkingv1.IngressRule { + rules := make([]networkingv1.IngressRule, len(flagd.Spec.Ingress.Hosts)) + for i, host := range flagd.Spec.Ingress.Hosts { + rules[i] = r.getRule(flagd, host) + } + return rules +} + +func (r FlagdIngress) getRule(flagd *api.Flagd, host string) networkingv1.IngressRule { + pathType := networkingv1.PathTypePrefix + if flagd.Spec.Ingress.PathType != "" { + pathType = flagd.Spec.Ingress.PathType + } + return networkingv1.IngressRule{ + Host: host, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: getFlagdPath(flagd.Spec.Ingress), + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: flagd.Name, + Port: networkingv1.ServiceBackendPort{ + Number: int32(r.FlagdConfig.FlagdPort), + }, + }, + Resource: nil, + }, + }, + { + Path: getOFREPPath(flagd.Spec.Ingress), + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: flagd.Name, + Port: networkingv1.ServiceBackendPort{ + Number: int32(r.FlagdConfig.OFREPPort), + }, + }, + Resource: nil, + }, + }, + { + Path: getSyncPath(flagd.Spec.Ingress), + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: flagd.Name, + Port: networkingv1.ServiceBackendPort{ + Number: int32(r.FlagdConfig.SyncPort), + }, + }, + Resource: nil, + }, + }, + }, + }, + }, + } +} + +func getFlagdPath(i api.IngressSpec) string { + path := defaultFlagdPath + if i.FlagdPath != "" { + path = i.FlagdPath + } + return path +} + +func getOFREPPath(i api.IngressSpec) string { + path := defaultOFREPPath + if i.OFREPPath != "" { + path = i.OFREPPath + } + return path +} + +func getSyncPath(i api.IngressSpec) string { + path := defaultSyncPath + if i.SyncPath != "" { + path = i.SyncPath + } + return path +} diff --git a/controllers/core/flagd/resources/ingress_test.go b/controllers/core/flagd/resources/ingress_test.go new file mode 100644 index 000000000..c15e56e99 --- /dev/null +++ b/controllers/core/flagd/resources/ingress_test.go @@ -0,0 +1,354 @@ +// nolint:dupl +package resources + +import ( + "context" + "testing" + + api "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestFlagdIngress_getIngress(t *testing.T) { + r := FlagdIngress{ + FlagdConfig: testFlagdConfig, + } + + ingressResult, err := r.GetResource(context.TODO(), &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + Spec: api.FlagdSpec{ + Ingress: api.IngressSpec{ + Enabled: true, + Annotations: map[string]string{ + "foo": "bar", + }, + Hosts: []string{ + "flagd.test", + "flagd.service", + }, + TLS: []networkingv1.IngressTLS{ + { + Hosts: []string{ + "flagd.test", + "flagd.service", + }, + SecretName: "my-secret", + }, + }, + IngressClassName: strPtr("nginx"), + PathType: networkingv1.PathTypeImplementationSpecific, + }, + }, + }) + + require.Nil(t, err) + + pathType := networkingv1.PathTypeImplementationSpecific + + require.NotNil(t, ingressResult) + require.Equal(t, networkingv1.IngressSpec{ + IngressClassName: strPtr("nginx"), + TLS: []networkingv1.IngressTLS{ + { + Hosts: []string{ + "flagd.test", + "flagd.service", + }, + SecretName: "my-secret", + }, + }, + Rules: []networkingv1.IngressRule{ + { + Host: "flagd.test", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/flagd", + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "my-flagd", + Port: networkingv1.ServiceBackendPort{ + Number: int32(testFlagdConfig.FlagdPort), + }, + }, + Resource: nil, + }, + }, + { + Path: "/ofrep", + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "my-flagd", + Port: networkingv1.ServiceBackendPort{ + Number: int32(r.FlagdConfig.OFREPPort), + }, + }, + Resource: nil, + }, + }, + { + Path: "/sync", + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "my-flagd", + Port: networkingv1.ServiceBackendPort{ + Number: int32(r.FlagdConfig.SyncPort), + }, + }, + Resource: nil, + }, + }, + }, + }, + }, + }, + { + Host: "flagd.service", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/flagd", + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "my-flagd", + Port: networkingv1.ServiceBackendPort{ + Number: int32(testFlagdConfig.FlagdPort), + }, + }, + Resource: nil, + }, + }, + { + Path: "/ofrep", + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "my-flagd", + Port: networkingv1.ServiceBackendPort{ + Number: int32(r.FlagdConfig.OFREPPort), + }, + }, + Resource: nil, + }, + }, + { + Path: "/sync", + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "my-flagd", + Port: networkingv1.ServiceBackendPort{ + Number: int32(r.FlagdConfig.SyncPort), + }, + }, + Resource: nil, + }, + }, + }, + }, + }, + }, + }, + }, ingressResult.(*networkingv1.Ingress).Spec) + +} + +func strPtr(s string) *string { + return &s +} + +func Test_areIngressesEqual(t *testing.T) { + type args struct { + old client.Object + new client.Object + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "has changed", + args: args{ + old: &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + IngressClassName: strPtr("nginx"), + }, + }, + new: &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + IngressClassName: strPtr("kong"), + }, + }, + }, + want: false, + }, + { + name: "has not changed", + args: args{ + old: &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + IngressClassName: strPtr("nginx"), + }, + }, + new: &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + IngressClassName: strPtr("nginx"), + }, + }, + }, + want: true, + }, + { + name: "old is not a service", + args: args{ + old: &v1.ConfigMap{}, + new: &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + IngressClassName: strPtr("nginx"), + }, + }, + }, + want: false, + }, + { + name: "new is not a service", + args: args{ + old: &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + IngressClassName: strPtr("nginx"), + }, + }, + new: &v1.ConfigMap{}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + i := &FlagdIngress{} + got := i.AreObjectsEqual(tt.args.old, tt.args.new) + + require.Equal(t, tt.want, got) + }) + } +} + +func Test_getFlagdPath(t *testing.T) { + type args struct { + i api.IngressSpec + } + tests := []struct { + name string + args args + want string + }{ + { + name: "default path", + args: args{ + i: api.IngressSpec{}, + }, + want: defaultFlagdPath, + }, + { + name: "custom path", + args: args{ + i: api.IngressSpec{ + FlagdPath: "my-path", + }, + }, + want: "my-path", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getFlagdPath(tt.args.i); got != tt.want { + t.Errorf("getFlagdPath() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_getOFREPPath(t *testing.T) { + type args struct { + i api.IngressSpec + } + tests := []struct { + name string + args args + want string + }{ + { + name: "default path", + args: args{ + i: api.IngressSpec{}, + }, + want: defaultOFREPPath, + }, + { + name: "custom path", + args: args{ + i: api.IngressSpec{ + OFREPPath: "my-path", + }, + }, + want: "my-path", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getOFREPPath(tt.args.i); got != tt.want { + t.Errorf("getOFREPPath() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_getSyncPath(t *testing.T) { + type args struct { + i api.IngressSpec + } + tests := []struct { + name string + args args + want string + }{ + { + name: "default path", + args: args{ + i: api.IngressSpec{}, + }, + want: defaultSyncPath, + }, + { + name: "custom path", + args: args{ + i: api.IngressSpec{ + SyncPath: "my-path", + }, + }, + want: "my-path", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getSyncPath(tt.args.i); got != tt.want { + t.Errorf("getSyncPath() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/controllers/core/flagd/resources/interface.go b/controllers/core/flagd/resources/interface.go new file mode 100644 index 000000000..db93e2fab --- /dev/null +++ b/controllers/core/flagd/resources/interface.go @@ -0,0 +1,13 @@ +package resources + +import ( + "context" + + "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type IFlagdResource interface { + GetResource(ctx context.Context, flagd *v1beta1.Flagd) (client.Object, error) + AreObjectsEqual(o1 client.Object, o2 client.Object) bool +} diff --git a/controllers/core/flagd/resources/mock/mock.go b/controllers/core/flagd/resources/mock/mock.go new file mode 100644 index 000000000..040941ad6 --- /dev/null +++ b/controllers/core/flagd/resources/mock/mock.go @@ -0,0 +1,66 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./controllers/core/flagd/resources/interface.go + +// Package commonmock is a generated GoMock package. +package commonmock + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1beta1 "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + client "sigs.k8s.io/controller-runtime/pkg/client" +) + +// MockIFlagdResource is a mock of IFlagdResource interface. +type MockIFlagdResource struct { + ctrl *gomock.Controller + recorder *MockIFlagdResourceMockRecorder +} + +// MockIFlagdResourceMockRecorder is the mock recorder for MockIFlagdResource. +type MockIFlagdResourceMockRecorder struct { + mock *MockIFlagdResource +} + +// NewMockIFlagdResource creates a new mock instance. +func NewMockIFlagdResource(ctrl *gomock.Controller) *MockIFlagdResource { + mock := &MockIFlagdResource{ctrl: ctrl} + mock.recorder = &MockIFlagdResourceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIFlagdResource) EXPECT() *MockIFlagdResourceMockRecorder { + return m.recorder +} + +// AreObjectsEqual mocks base method. +func (m *MockIFlagdResource) AreObjectsEqual(o1, o2 client.Object) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AreObjectsEqual", o1, o2) + ret0, _ := ret[0].(bool) + return ret0 +} + +// AreObjectsEqual indicates an expected call of AreObjectsEqual. +func (mr *MockIFlagdResourceMockRecorder) AreObjectsEqual(o1, o2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AreObjectsEqual", reflect.TypeOf((*MockIFlagdResource)(nil).AreObjectsEqual), o1, o2) +} + +// GetResource mocks base method. +func (m *MockIFlagdResource) GetResource(ctx context.Context, flagd *v1beta1.Flagd) (client.Object, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetResource", ctx, flagd) + ret0, _ := ret[0].(client.Object) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetResource indicates an expected call of GetResource. +func (mr *MockIFlagdResourceMockRecorder) GetResource(ctx, flagd interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetResource", reflect.TypeOf((*MockIFlagdResource)(nil).GetResource), ctx, flagd) +} diff --git a/controllers/core/flagd/resources/service.go b/controllers/core/flagd/resources/service.go new file mode 100644 index 000000000..5a46b19e9 --- /dev/null +++ b/controllers/core/flagd/resources/service.go @@ -0,0 +1,90 @@ +package resources + +import ( + "context" + "reflect" + + api "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + "github.com/open-feature/open-feature-operator/common" + "github.com/open-feature/open-feature-operator/controllers/core/flagd/common" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type FlagdService struct { + FlagdConfig resources.FlagdConfiguration +} + +func (r FlagdService) AreObjectsEqual(o1 client.Object, o2 client.Object) bool { + oldService, ok := o1.(*v1.Service) + if !ok { + return false + } + + newService, ok := o2.(*v1.Service) + if !ok { + return false + } + + return reflect.DeepEqual(oldService.Spec, newService.Spec) +} + +func (r FlagdService) GetResource(_ context.Context, flagd *api.Flagd) (client.Object, error) { + return &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: flagd.Name, + Namespace: flagd.Namespace, + Labels: map[string]string{ + "app": flagd.Name, + "app.kubernetes.io/name": flagd.Name, + "app.kubernetes.io/managed-by": common.ManagedByAnnotationValue, + "app.kubernetes.io/version": r.FlagdConfig.Tag, + }, + Annotations: flagd.Annotations, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: flagd.APIVersion, + Kind: flagd.Kind, + Name: flagd.Name, + UID: flagd.UID, + }}, + }, + Spec: v1.ServiceSpec{ + Selector: map[string]string{ + "app": flagd.Name, + }, + Ports: []v1.ServicePort{ + { + Name: "flagd", + Port: int32(r.FlagdConfig.FlagdPort), + TargetPort: intstr.IntOrString{ + IntVal: int32(r.FlagdConfig.FlagdPort), + }, + }, + { + Name: "ofrep", + Port: int32(r.FlagdConfig.OFREPPort), + TargetPort: intstr.IntOrString{ + IntVal: int32(r.FlagdConfig.OFREPPort), + }, + }, + { + Name: "sync", + Port: int32(r.FlagdConfig.SyncPort), + TargetPort: intstr.IntOrString{ + IntVal: int32(r.FlagdConfig.SyncPort), + }, + }, + { + Name: "metrics", + Port: int32(r.FlagdConfig.ManagementPort), + TargetPort: intstr.IntOrString{ + IntVal: int32(r.FlagdConfig.ManagementPort), + }, + }, + }, + Type: flagd.Spec.ServiceType, + }, + }, nil +} diff --git a/controllers/core/flagd/resources/service_test.go b/controllers/core/flagd/resources/service_test.go new file mode 100644 index 000000000..88baf9200 --- /dev/null +++ b/controllers/core/flagd/resources/service_test.go @@ -0,0 +1,111 @@ +// nolint:dupl +package resources + +import ( + "context" + "testing" + + api "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestFlagdService_getService(t *testing.T) { + r := FlagdService{ + FlagdConfig: testFlagdConfig, + } + + svc, err := r.GetResource(context.TODO(), &api.Flagd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-flagd", + Namespace: "my-namespace", + }, + Spec: api.FlagdSpec{ + ServiceType: "ClusterIP", + }, + }) + + require.Nil(t, err) + require.NotNil(t, svc) + require.IsType(t, &v1.Service{}, svc) +} + +func Test_areServicesEqual(t *testing.T) { + type args struct { + old client.Object + new client.Object + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "has changed", + args: args{ + old: &v1.Service{ + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeNodePort, + }, + }, + new: &v1.Service{ + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeClusterIP, + }, + }, + }, + want: false, + }, + { + name: "has not changed", + args: args{ + old: &v1.Service{ + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeClusterIP, + }, + }, + new: &v1.Service{ + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeClusterIP, + }, + }, + }, + want: true, + }, + { + name: "old is not a service", + args: args{ + old: &v1.ConfigMap{}, + new: &v1.Service{ + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeClusterIP, + }, + }, + }, + want: false, + }, + { + name: "new is not a service", + args: args{ + old: &v1.Service{ + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeClusterIP, + }, + }, + new: &v1.ConfigMap{}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + s := &FlagdService{} + got := s.AreObjectsEqual(tt.args.old, tt.args.new) + + require.Equal(t, tt.want, got) + }) + } +} diff --git a/docs/crds.md b/docs/crds.md index 12169b6ad..360da228a 100644 --- a/docs/crds.md +++ b/docs/crds.md @@ -12,6 +12,8 @@ Resource Types: - [FeatureFlagSource](#featureflagsource) +- [Flagd](#flagd) + @@ -783,4 +785,246 @@ inside a container.
true + + +## Flagd +[↩ Parent](#coreopenfeaturedevv1beta1 ) + + + + + + +Flagd is the Schema for the flagds API + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringcore.openfeature.dev/v1beta1true
kindstringFlagdtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + FlagdSpec defines the desired state of Flagd
+
false
statusobject + FlagdStatus defines the observed state of Flagd
+
false
+ + +### Flagd.spec +[↩ Parent](#flagd) + + + +FlagdSpec defines the desired state of Flagd + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
featureFlagSourcestring + FeatureFlagSource references to a FeatureFlagSource from which the created flagd instance retrieves +the feature flag configurations
+
true
ingressobject + Ingress
+
false
replicasinteger + Replicas defines the number of replicas to create for the service. +Default: 1
+
+ Format: int32
+ Default: 1
+
false
serviceAccountNamestring + ServiceAccountName the service account name for the flagd deployment
+
false
serviceTypeenum + ServiceType represents the type of Service to create. +Must be one of: ClusterIP, NodePort, LoadBalancer, and ExternalName. +Default: ClusterIP
+
+ Enum: ClusterIP, NodePort, LoadBalancer, ExternalName
+ Default: ClusterIP
+
false
+ + +### Flagd.spec.ingress +[↩ Parent](#flagdspec) + + + +Ingress + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
annotationsmap[string]string + Annotations the annotations to be added to the ingress
+
false
enabledboolean + Enabled enables/disables the ingress for flagd
+
false
flagdPathstring + FlagdPath is the path to be used for accessing the flagd flag evaluation API
+
false
hosts[]string + Hosts list of hosts to be added to the ingress
+
false
ingressClassNamestring + IngressClassName defines the name if the ingress class to be used for flagd
+
false
ofrepPathstring + OFREPPath is the path to be used for accessing the OFREP API
+
false
pathTypestring + PathType is the path type to be used for the ingress rules
+
false
syncPathstring + SyncPath is the path to be used for accessing the sync API
+
false
tls[]object + TLS configuration for the ingress
+
false
+ + +### Flagd.spec.ingress.tls[index] +[↩ Parent](#flagdspecingress) + + + +IngressTLS describes the transport layer security associated with an Ingress. + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
hosts[]string + Hosts are a list of hosts included in the TLS certificate. The values in +this list must match the name/s used in the tlsSecret. Defaults to the +wildcard host setting for the loadbalancer controller fulfilling this +Ingress, if left unspecified.
+
false
secretNamestring + SecretName is the name of the secret used to terminate TLS traffic on +port 443. Field is left optional to allow TLS routing based on SNI +hostname alone. If the SNI host in a listener conflicts with the "Host" +header field used by an IngressRule, the SNI host is used for termination +and value of the Host header is used for routing.
+
false
\ No newline at end of file diff --git a/main.go b/main.go index 3a2a63153..48b739689 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,8 @@ import ( "github.com/open-feature/open-feature-operator/common/flagdproxy" "github.com/open-feature/open-feature-operator/common/types" "github.com/open-feature/open-feature-operator/controllers/core/featureflagsource" + "github.com/open-feature/open-feature-operator/controllers/core/flagd" + flagdresources "github.com/open-feature/open-feature-operator/controllers/core/flagd/resources" webhooks "github.com/open-feature/open-feature-operator/webhooks" "go.uber.org/zap/zapcore" appsV1 "k8s.io/api/apps/v1" @@ -181,6 +183,47 @@ func main() { os.Exit(1) } + flagdContainerInjector := &flagdinjector.FlagdContainerInjector{ + Client: mgr.GetClient(), + Logger: ctrl.Log.WithName("flagd-container injector"), + FlagdProxyConfig: kph.Config(), + FlagdResourceRequirements: *resources, + Image: env.SidecarImage, + Tag: env.SidecarTag, + } + + if env.FlagdResourceEnabled { + flagdControllerLogger := ctrl.Log.WithName("Flagd Controller") + + flagdResourceReconciler := &flagd.ResourceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: flagdControllerLogger, + } + flagdConfig := flagd.NewFlagdConfiguration(env) + + if err = (&flagd.FlagdReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + ResourceReconciler: flagdResourceReconciler, + FlagdDeployment: &flagdresources.FlagdDeployment{ + Client: mgr.GetClient(), + Log: flagdControllerLogger, + FlagdInjector: flagdContainerInjector, + FlagdConfig: flagdConfig, + }, + FlagdService: &flagdresources.FlagdService{ + FlagdConfig: flagdConfig, + }, + FlagdIngress: &flagdresources.FlagdIngress{ + FlagdConfig: flagdConfig, + }, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Flagd") + os.Exit(1) + } + } + if env.FlagsValidationEnabled { if err = (&corev1beta1.FeatureFlag{}).SetupWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create the validation webhook for FeatureFlag CRD", "webhook", "FeatureFlag") @@ -195,14 +238,7 @@ func main() { Log: ctrl.Log.WithName("mutating-pod-webhook"), FlagdProxyConfig: kph.Config(), Env: env, - FlagdInjector: &flagdinjector.FlagdContainerInjector{ - Client: mgr.GetClient(), - Logger: ctrl.Log.WithName("flagd-container injector"), - FlagdProxyConfig: kph.Config(), - FlagdResourceRequirements: *resources, - Image: env.SidecarImage, - Tag: env.SidecarTag, - }, + FlagdInjector: flagdContainerInjector, } hookServer.Register("/mutate-v1-pod", &webhook.Admission{Handler: podMutator}) diff --git a/test/e2e/kuttl/flagd-with-ingress-custom-paths/00-assert.yaml b/test/e2e/kuttl/flagd-with-ingress-custom-paths/00-assert.yaml new file mode 100644 index 000000000..867f5ca62 --- /dev/null +++ b/test/e2e/kuttl/flagd-with-ingress-custom-paths/00-assert.yaml @@ -0,0 +1,120 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: flagd-sample + app.kubernetes.io/managed-by: open-feature-operator + app.kubernetes.io/name: flagd-sample + name: flagd-sample + ownerReferences: + - apiVersion: core.openfeature.dev/v1beta1 + kind: Flagd + name: flagd-sample +spec: + replicas: 2 + selector: + matchLabels: + app: flagd-sample + template: + metadata: + creationTimestamp: null + labels: + app: flagd-sample + app.kubernetes.io/managed-by: open-feature-operator + app.kubernetes.io/name: flagd-sample + spec: + containers: + - name: flagd + ports: + - containerPort: 8014 + name: management + protocol: TCP + - containerPort: 8013 + name: flagd + protocol: TCP + - containerPort: 8016 + name: ofrep + protocol: TCP + - containerPort: 8015 + name: sync + protocol: TCP + serviceAccount: default + serviceAccountName: default +status: + readyReplicas: 2 +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: flagd-sample + app.kubernetes.io/managed-by: open-feature-operator + app.kubernetes.io/name: flagd-sample + name: flagd-sample + ownerReferences: + - apiVersion: core.openfeature.dev/v1beta1 + kind: Flagd + name: flagd-sample +spec: + ports: + - name: flagd + port: 8013 + protocol: TCP + targetPort: 8013 + - name: ofrep + port: 8016 + protocol: TCP + targetPort: 8016 + - name: sync + port: 8015 + protocol: TCP + targetPort: 8015 + - name: metrics + port: 8014 + protocol: TCP + targetPort: 8014 + selector: + app: flagd-sample + type: ClusterIP +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + labels: + app: flagd-sample + app.kubernetes.io/managed-by: open-feature-operator + app.kubernetes.io/name: flagd-sample + name: flagd-sample + annotations: + nginx.ingress.kubernetes.io/force-ssl-redirect: "false" + ownerReferences: + - apiVersion: core.openfeature.dev/v1beta1 + kind: Flagd + name: flagd-sample +spec: + ingressClassName: nginx + rules: + - host: flagd-sample + http: + paths: + - backend: + service: + name: flagd-sample + port: + number: 8013 + path: /flagd(/|$)(.*) + pathType: ImplementationSpecific + - backend: + service: + name: flagd-sample + port: + number: 8016 + path: /ofrep(/|$)(.*) + pathType: ImplementationSpecific + - backend: + service: + name: flagd-sample + port: + number: 8015 + path: /sync(/|$)(.*) + pathType: ImplementationSpecific diff --git a/test/e2e/kuttl/flagd-with-ingress-custom-paths/00-install.yaml b/test/e2e/kuttl/flagd-with-ingress-custom-paths/00-install.yaml new file mode 100644 index 000000000..9f5c0d5b0 --- /dev/null +++ b/test/e2e/kuttl/flagd-with-ingress-custom-paths/00-install.yaml @@ -0,0 +1,43 @@ +apiVersion: core.openfeature.dev/v1beta1 +kind: FeatureFlag +metadata: + name: featureflag-sample +spec: + flagSpec: + flags: + "simple-flag": + state: "ENABLED" + variants: + "on": true + "off": false + defaultVariant: "on" +--- +apiVersion: core.openfeature.dev/v1beta1 +kind: FeatureFlagSource +metadata: + name: end-to-end +spec: + sources: + - source: featureflag-sample + provider: kubernetes +--- +apiVersion: core.openfeature.dev/v1beta1 +kind: Flagd +metadata: + name: flagd-sample +spec: + replicas: 2 + serviceType: ClusterIP + serviceAccountName: default + featureFlagSource: end-to-end + ingress: + enabled: true + annotations: + nginx.ingress.kubernetes.io/force-ssl-redirect: "false" + hosts: + - flagd-sample + ingressClassName: nginx + pathType: ImplementationSpecific + flagdPath: /flagd(/|$)(.*) + ofrepPath: /ofrep(/|$)(.*) + syncPath: /sync(/|$)(.*) diff --git a/test/e2e/kuttl/flagd-with-ingress-default-paths/00-assert.yaml b/test/e2e/kuttl/flagd-with-ingress-default-paths/00-assert.yaml new file mode 100644 index 000000000..c9add36ea --- /dev/null +++ b/test/e2e/kuttl/flagd-with-ingress-default-paths/00-assert.yaml @@ -0,0 +1,120 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: flagd-sample + app.kubernetes.io/managed-by: open-feature-operator + app.kubernetes.io/name: flagd-sample + name: flagd-sample + ownerReferences: + - apiVersion: core.openfeature.dev/v1beta1 + kind: Flagd + name: flagd-sample +spec: + replicas: 2 + selector: + matchLabels: + app: flagd-sample + template: + metadata: + creationTimestamp: null + labels: + app: flagd-sample + app.kubernetes.io/managed-by: open-feature-operator + app.kubernetes.io/name: flagd-sample + spec: + containers: + - name: flagd + ports: + - containerPort: 8014 + name: management + protocol: TCP + - containerPort: 8013 + name: flagd + protocol: TCP + - containerPort: 8016 + name: ofrep + protocol: TCP + - containerPort: 8015 + name: sync + protocol: TCP + serviceAccount: default + serviceAccountName: default +status: + readyReplicas: 2 +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: flagd-sample + app.kubernetes.io/managed-by: open-feature-operator + app.kubernetes.io/name: flagd-sample + name: flagd-sample + ownerReferences: + - apiVersion: core.openfeature.dev/v1beta1 + kind: Flagd + name: flagd-sample +spec: + ports: + - name: flagd + port: 8013 + protocol: TCP + targetPort: 8013 + - name: ofrep + port: 8016 + protocol: TCP + targetPort: 8016 + - name: sync + port: 8015 + protocol: TCP + targetPort: 8015 + - name: metrics + port: 8014 + protocol: TCP + targetPort: 8014 + selector: + app: flagd-sample + type: ClusterIP +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + labels: + app: flagd-sample + app.kubernetes.io/managed-by: open-feature-operator + app.kubernetes.io/name: flagd-sample + name: flagd-sample + annotations: + nginx.ingress.kubernetes.io/force-ssl-redirect: "false" + ownerReferences: + - apiVersion: core.openfeature.dev/v1beta1 + kind: Flagd + name: flagd-sample +spec: + ingressClassName: nginx + rules: + - host: flagd-sample + http: + paths: + - backend: + service: + name: flagd-sample + port: + number: 8013 + path: /flagd + pathType: ImplementationSpecific + - backend: + service: + name: flagd-sample + port: + number: 8016 + path: /ofrep + pathType: ImplementationSpecific + - backend: + service: + name: flagd-sample + port: + number: 8015 + path: /sync + pathType: ImplementationSpecific diff --git a/test/e2e/kuttl/flagd-with-ingress-default-paths/00-install.yaml b/test/e2e/kuttl/flagd-with-ingress-default-paths/00-install.yaml new file mode 100644 index 000000000..2ad633a95 --- /dev/null +++ b/test/e2e/kuttl/flagd-with-ingress-default-paths/00-install.yaml @@ -0,0 +1,40 @@ +apiVersion: core.openfeature.dev/v1beta1 +kind: FeatureFlag +metadata: + name: featureflag-sample +spec: + flagSpec: + flags: + "simple-flag": + state: "ENABLED" + variants: + "on": true + "off": false + defaultVariant: "on" +--- +apiVersion: core.openfeature.dev/v1beta1 +kind: FeatureFlagSource +metadata: + name: end-to-end +spec: + sources: + - source: featureflag-sample + provider: kubernetes +--- +apiVersion: core.openfeature.dev/v1beta1 +kind: Flagd +metadata: + name: flagd-sample +spec: + replicas: 2 + serviceType: ClusterIP + serviceAccountName: default + featureFlagSource: end-to-end + ingress: + enabled: true + annotations: + nginx.ingress.kubernetes.io/force-ssl-redirect: "false" + hosts: + - flagd-sample + ingressClassName: nginx + pathType: ImplementationSpecific