diff --git a/infra/feast-operator/api/feastversion/version.go b/infra/feast-operator/api/feastversion/version.go new file mode 100644 index 00000000000..ac97cd03266 --- /dev/null +++ b/infra/feast-operator/api/feastversion/version.go @@ -0,0 +1,20 @@ +/* +Copyright 2024 Feast Community. + +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 feastversion + +// Feast release version +const FeastVersion = "0.40.0" diff --git a/infra/feast-operator/api/v1alpha1/featurestore_types.go b/infra/feast-operator/api/v1alpha1/featurestore_types.go index 030ff408b48..1afd7069f0b 100644 --- a/infra/feast-operator/api/v1alpha1/featurestore_types.go +++ b/infra/feast-operator/api/v1alpha1/featurestore_types.go @@ -20,24 +20,59 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! -// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. +const ( + // Feast phases: + ReadyPhase = "Ready" + PendingPhase = "Pending" + FailedPhase = "Failed" + + // Feast condition types: + ClientReadyType = "Client" + RegistryReadyType = "Registry" + ReadyType = "FeatureStore" + + // Feast condition reasons: + ReadyReason = "Ready" + FailedReason = "FeatureStoreFailed" + RegistryFailedReason = "RegistryDeploymentFailed" + ClientFailedReason = "ClientDeploymentFailed" + + // Feast condition messages: + ReadyMessage = "FeatureStore installation complete" + RegistryReadyMessage = "Registry installation complete" + ClientReadyMessage = "Client installation complete" + + // entity_key_serialization_version + SerializationVersion = 3 +) // FeatureStoreSpec defines the desired state of FeatureStore type FeatureStoreSpec struct { // +kubebuilder:validation:Pattern="^[A-Za-z0-9][A-Za-z0-9_]*$" - // FeastProject is the Feast project id. This can be any alphanumeric string with underscores, but it cannot start with an underscore. + // FeastProject is the Feast project id. This can be any alphanumeric string with underscores, but it cannot start with an underscore. Required. FeastProject string `json:"feastProject"` } // FeatureStoreStatus defines the observed state of FeatureStore type FeatureStoreStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file + Applied FeatureStoreSpec `json:"applied,omitempty"` + ClientConfigMap string `json:"clientConfigMap,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + FeastVersion string `json:"feastVersion,omitempty"` + Phase string `json:"phase,omitempty"` + ServiceUrls ServiceUrls `json:"serviceUrls,omitempty"` +} + +// ServiceUrls +type ServiceUrls struct { + Registry string `json:"registry,omitempty"` } //+kubebuilder:object:root=true //+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=feast +//+kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // FeatureStore is the Schema for the featurestores API type FeatureStore struct { diff --git a/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go b/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go index 3f664edded7..b8e410a616e 100644 --- a/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -21,6 +21,7 @@ limitations under the License. package v1alpha1 import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -30,7 +31,7 @@ func (in *FeatureStore) DeepCopyInto(out *FeatureStore) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) out.Spec = in.Spec - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureStore. @@ -101,6 +102,15 @@ func (in *FeatureStoreSpec) DeepCopy() *FeatureStoreSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FeatureStoreStatus) DeepCopyInto(out *FeatureStoreStatus) { *out = *in + out.Applied = in.Applied + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.ServiceUrls = in.ServiceUrls } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureStoreStatus. @@ -112,3 +122,18 @@ func (in *FeatureStoreStatus) DeepCopy() *FeatureStoreStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceUrls) DeepCopyInto(out *ServiceUrls) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceUrls. +func (in *ServiceUrls) DeepCopy() *ServiceUrls { + if in == nil { + return nil + } + out := new(ServiceUrls) + in.DeepCopyInto(out) + return out +} diff --git a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml index 8c403f78885..5b989cc2a7f 100644 --- a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml +++ b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml @@ -8,17 +8,15 @@ metadata: "apiVersion": "feast.dev/v1alpha1", "kind": "FeatureStore", "metadata": { - "labels": { - "app.kubernetes.io/managed-by": "kustomize", - "app.kubernetes.io/name": "feast-operator" - }, - "name": "featurestore-sample" + "name": "sample" }, - "spec": null + "spec": { + "feastProject": "my_project" + } } ] capabilities: Basic Install - createdAt: "2024-10-09T16:16:53Z" + createdAt: "2024-10-21T16:35:01Z" operators.operatorframework.io/builder: operator-sdk-v1.37.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 name: feast-operator.v0.40.0 @@ -41,6 +39,29 @@ spec: spec: clusterPermissions: - rules: + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - update + - watch + - apiGroups: + - "" + resources: + - configmaps + - services + verbs: + - create + - delete + - get + - list + - update + - watch - apiGroups: - feast.dev resources: diff --git a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml index 43df8e3f845..2fb15b432a7 100644 --- a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml +++ b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml @@ -11,10 +11,19 @@ spec: kind: FeatureStore listKind: FeatureStoreList plural: featurestores + shortNames: + - feast singular: featurestore scope: Namespaced versions: - - name: v1alpha1 + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 schema: openAPIV3Schema: description: FeatureStore is the Schema for the featurestores API @@ -42,7 +51,7 @@ spec: feastProject: description: FeastProject is the Feast project id. This can be any alphanumeric string with underscores, but it cannot start with an - underscore. + underscore. Required. pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ type: string required: @@ -50,6 +59,100 @@ spec: type: object status: description: FeatureStoreStatus defines the observed state of FeatureStore + properties: + applied: + description: FeatureStoreSpec defines the desired state of FeatureStore + properties: + feastProject: + description: FeastProject is the Feast project id. This can be + any alphanumeric string with underscores, but it cannot start + with an underscore. Required. + pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ + type: string + required: + - feastProject + type: object + clientConfigMap: + type: string + conditions: + items: + description: "Condition contains details for one aspect of the current + state of this API Resource.\n---\nThis struct is intended for + direct use as an array at the field path .status.conditions. For + example,\n\n\n\ttype FooStatus struct{\n\t // Represents the + observations of a foo's current state.\n\t // Known .status.conditions.type + are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // + +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t + \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" + patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t + \ // other fields\n\t}" + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + feastVersion: + type: string + phase: + type: string + serviceUrls: + description: ServiceUrls + properties: + registry: + type: string + type: object type: object type: object served: true diff --git a/infra/feast-operator/cmd/main.go b/infra/feast-operator/cmd/main.go index 3ca6c895088..eae4f8b1214 100644 --- a/infra/feast-operator/cmd/main.go +++ b/infra/feast-operator/cmd/main.go @@ -25,10 +25,12 @@ import ( // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" @@ -116,6 +118,13 @@ func main() { // if you are doing or is intended to do any operation such as perform cleanups // after the manager stops then its usage might be unsafe. // LeaderElectionReleaseOnCancel: true, + Client: client.Options{ + Cache: &client.CacheOptions{ + DisableFor: []client.Object{ + &corev1.ConfigMap{}, + }, + }, + }, }) if err != nil { setupLog.Error(err, "unable to start manager") diff --git a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml index d6bd0536922..8c5c2e62a6d 100644 --- a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml +++ b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml @@ -11,10 +11,19 @@ spec: kind: FeatureStore listKind: FeatureStoreList plural: featurestores + shortNames: + - feast singular: featurestore scope: Namespaced versions: - - name: v1alpha1 + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 schema: openAPIV3Schema: description: FeatureStore is the Schema for the featurestores API @@ -42,7 +51,7 @@ spec: feastProject: description: FeastProject is the Feast project id. This can be any alphanumeric string with underscores, but it cannot start with an - underscore. + underscore. Required. pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ type: string required: @@ -50,6 +59,100 @@ spec: type: object status: description: FeatureStoreStatus defines the observed state of FeatureStore + properties: + applied: + description: FeatureStoreSpec defines the desired state of FeatureStore + properties: + feastProject: + description: FeastProject is the Feast project id. This can be + any alphanumeric string with underscores, but it cannot start + with an underscore. Required. + pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ + type: string + required: + - feastProject + type: object + clientConfigMap: + type: string + conditions: + items: + description: "Condition contains details for one aspect of the current + state of this API Resource.\n---\nThis struct is intended for + direct use as an array at the field path .status.conditions. For + example,\n\n\n\ttype FooStatus struct{\n\t // Represents the + observations of a foo's current state.\n\t // Known .status.conditions.type + are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // + +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t + \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" + patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t + \ // other fields\n\t}" + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + feastVersion: + type: string + phase: + type: string + serviceUrls: + description: ServiceUrls + properties: + registry: + type: string + type: object type: object type: object served: true diff --git a/infra/feast-operator/config/rbac/role.yaml b/infra/feast-operator/config/rbac/role.yaml index f0bb2016af1..5ee64d47051 100644 --- a/infra/feast-operator/config/rbac/role.yaml +++ b/infra/feast-operator/config/rbac/role.yaml @@ -4,6 +4,29 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - "" + resources: + - configmaps + - services + verbs: + - create + - delete + - get + - list + - update + - watch - apiGroups: - feast.dev resources: diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore.yaml index 2800d87e358..3eb62850435 100644 --- a/infra/feast-operator/config/samples/v1alpha1_featurestore.yaml +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore.yaml @@ -1,9 +1,6 @@ apiVersion: feast.dev/v1alpha1 kind: FeatureStore metadata: - labels: - app.kubernetes.io/name: feast-operator - app.kubernetes.io/managed-by: kustomize - name: featurestore-sample + name: sample spec: - # TODO(user): Add fields here + feastProject: my_project diff --git a/infra/feast-operator/dist/install.yaml b/infra/feast-operator/dist/install.yaml index 63b3a742b16..a06ae82332c 100644 --- a/infra/feast-operator/dist/install.yaml +++ b/infra/feast-operator/dist/install.yaml @@ -19,10 +19,19 @@ spec: kind: FeatureStore listKind: FeatureStoreList plural: featurestores + shortNames: + - feast singular: featurestore scope: Namespaced versions: - - name: v1alpha1 + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 schema: openAPIV3Schema: description: FeatureStore is the Schema for the featurestores API @@ -50,7 +59,7 @@ spec: feastProject: description: FeastProject is the Feast project id. This can be any alphanumeric string with underscores, but it cannot start with an - underscore. + underscore. Required. pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ type: string required: @@ -58,6 +67,100 @@ spec: type: object status: description: FeatureStoreStatus defines the observed state of FeatureStore + properties: + applied: + description: FeatureStoreSpec defines the desired state of FeatureStore + properties: + feastProject: + description: FeastProject is the Feast project id. This can be + any alphanumeric string with underscores, but it cannot start + with an underscore. Required. + pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ + type: string + required: + - feastProject + type: object + clientConfigMap: + type: string + conditions: + items: + description: "Condition contains details for one aspect of the current + state of this API Resource.\n---\nThis struct is intended for + direct use as an array at the field path .status.conditions. For + example,\n\n\n\ttype FooStatus struct{\n\t // Represents the + observations of a foo's current state.\n\t // Known .status.conditions.type + are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // + +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t + \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" + patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t + \ // other fields\n\t}" + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + feastVersion: + type: string + phase: + type: string + serviceUrls: + description: ServiceUrls + properties: + registry: + type: string + type: object type: object type: object served: true @@ -170,6 +273,29 @@ kind: ClusterRole metadata: name: feast-operator-manager-role rules: +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - "" + resources: + - configmaps + - services + verbs: + - create + - delete + - get + - list + - update + - watch - apiGroups: - feast.dev resources: diff --git a/infra/feast-operator/internal/controller/featurestore_controller.go b/infra/feast-operator/internal/controller/featurestore_controller.go index d56a7ff024d..17227293d80 100644 --- a/infra/feast-operator/internal/controller/featurestore_controller.go +++ b/infra/feast-operator/internal/controller/featurestore_controller.go @@ -18,13 +18,28 @@ package controller import ( "context" + "reflect" + "time" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" +) + +// Constants for requeue +const ( + RequeueDelayError = 5 * time.Second ) // FeatureStoreReconciler reconciles a FeatureStore object @@ -36,27 +51,99 @@ type FeatureStoreReconciler struct { //+kubebuilder:rbac:groups=feast.dev,resources=featurestores,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=feast.dev,resources=featurestores/status,verbs=get;update;patch //+kubebuilder:rbac:groups=feast.dev,resources=featurestores/finalizers,verbs=update +//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;create;update;watch;delete +//+kubebuilder:rbac:groups=core,resources=services;configmaps,verbs=get;list;create;update;watch;delete // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the FeatureStore object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.17.3/pkg/reconcile -func (r *FeatureStoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) +func (r *FeatureStoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, recErr error) { + logger := log.FromContext(ctx) + + cr := &feastdevv1alpha1.FeatureStore{} + err := r.Get(ctx, req.NamespacedName, cr) + if err != nil { + if apierrors.IsNotFound(err) { + // CR deleted since request queued, child objects getting GC'd, no requeue + logger.V(1).Info("FeatureStore CR not found, has been deleted") + return ctrl.Result{}, nil + } + // error fetching FeatureStore instance, requeue and try again + logger.Error(err, "Unable to get FeatureStore CR") + return ctrl.Result{}, err + } + currentStatus := cr.Status.DeepCopy() - // TODO(user): your logic here + applyDefaultsToStatus(cr) + result, recErr = r.deployFeast(ctx, cr) + if cr.DeletionTimestamp == nil && !reflect.DeepEqual(currentStatus, cr.Status) { + if err := r.Client.Status().Update(ctx, cr); err != nil { + if errors.IsConflict(err) { + logger.Info("FeatureStore object modified, retry syncing status") + // Re-queue and preserve existing recErr + result = ctrl.Result{Requeue: true, RequeueAfter: RequeueDelayError} + } + logger.Error(err, "Error updating the FeatureStore status") + if recErr == nil { + // There is no existing recErr. Set it to the status update error + recErr = err + } + } + } - return ctrl.Result{}, nil + return result, recErr +} + +func (r *FeatureStoreReconciler) deployFeast(ctx context.Context, cr *feastdevv1alpha1.FeatureStore) (result ctrl.Result, err error) { + logger := log.FromContext(ctx) + condition := metav1.Condition{ + Type: feastdevv1alpha1.ReadyType, + Status: metav1.ConditionTrue, + Reason: feastdevv1alpha1.ReadyReason, + Message: feastdevv1alpha1.ReadyMessage, + } + feast := services.FeastServices{ + Client: r.Client, + Context: ctx, + FeatureStore: cr, + Scheme: r.Scheme, + } + err = feast.Deploy() + if err != nil { + condition = metav1.Condition{ + Type: feastdevv1alpha1.ReadyType, + Status: metav1.ConditionFalse, + Reason: feastdevv1alpha1.FailedReason, + Message: "Error: " + err.Error(), + } + result = ctrl.Result{Requeue: true} + } + logger.Info(condition.Message) + apimeta.SetStatusCondition(&cr.Status.Conditions, condition) + + if apimeta.IsStatusConditionTrue(cr.Status.Conditions, feastdevv1alpha1.ReadyType) { + cr.Status.Phase = feastdevv1alpha1.ReadyPhase + } else if apimeta.IsStatusConditionFalse(cr.Status.Conditions, feastdevv1alpha1.ReadyType) { + cr.Status.Phase = feastdevv1alpha1.FailedPhase + } else { + cr.Status.Phase = feastdevv1alpha1.PendingPhase + } + + return result, err } // SetupWithManager sets up the controller with the Manager. func (r *FeatureStoreReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&feastdevv1alpha1.FeatureStore{}). + Owns(&corev1.ConfigMap{}). + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}). Complete(r) } + +func applyDefaultsToStatus(cr *feastdevv1alpha1.FeatureStore) { + cr.Status.Applied.FeastProject = cr.Spec.FeastProject + cr.Status.FeastVersion = feastversion.FeastVersion +} diff --git a/infra/feast-operator/internal/controller/featurestore_controller_test.go b/infra/feast-operator/internal/controller/featurestore_controller_test.go index d4caf254977..5b1e41bb309 100644 --- a/infra/feast-operator/internal/controller/featurestore_controller_test.go +++ b/infra/feast-operator/internal/controller/featurestore_controller_test.go @@ -18,18 +18,28 @@ package controller import ( "context" + "encoding/base64" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" ) +const feastProject = "test_project" + var _ = Describe("FeatureStore Controller", func() { Context("When reconciling a resource", func() { const resourceName = "test-resource" @@ -38,7 +48,7 @@ var _ = Describe("FeatureStore Controller", func() { typeNamespacedName := types.NamespacedName{ Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed + Namespace: "default", } featurestore := &feastdevv1alpha1.FeatureStore{} @@ -51,14 +61,12 @@ var _ = Describe("FeatureStore Controller", func() { Name: resourceName, Namespace: "default", }, - Spec: feastdevv1alpha1.FeatureStoreSpec{FeastProject: "my_project"}, + Spec: feastdevv1alpha1.FeatureStoreSpec{FeastProject: feastProject}, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) } }) - AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. resource := &feastdevv1alpha1.FeatureStore{} err := k8sClient.Get(ctx, typeNamespacedName, resource) Expect(err).NotTo(HaveOccurred()) @@ -66,6 +74,7 @@ var _ = Describe("FeatureStore Controller", func() { By("Cleanup the specific resource instance FeatureStore") Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) }) + It("should successfully reconcile the resource", func() { By("Reconciling the created resource") controllerReconciler := &FeatureStoreReconciler{ @@ -77,8 +86,249 @@ var _ = Describe("FeatureStore Controller", func() { NamespacedName: typeNamespacedName, }) Expect(err).NotTo(HaveOccurred()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + feast := services.FeastServices{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + } + Expect(resource.Status).NotTo(BeNil()) + Expect(resource.Status.FeastVersion).To(Equal(feastversion.FeastVersion)) + Expect(resource.Status.ClientConfigMap).To(Equal(feast.GetFeastServiceName(services.ClientFeastType))) + Expect(resource.Status.ServiceUrls.Registry).To(Equal(feast.GetFeastServiceName(services.RegistryFeastType) + "." + resource.Namespace + ".svc.cluster.local:80")) + Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) + Expect(resource.Status.Conditions).NotTo(BeEmpty()) + + cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.ReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.RegistryReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ClientReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.ClientReadyMessage)) + + Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.ReadyPhase)) + + deploy := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: feast.GetFeastServiceName(services.RegistryFeastType), + Namespace: resource.Namespace, + }, + deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) + + svc := &corev1.Service{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: feast.GetFeastServiceName(services.RegistryFeastType), + Namespace: resource.Namespace, + }, + svc) + Expect(err).NotTo(HaveOccurred()) + Expect(controllerutil.HasControllerReference(svc)).To(BeTrue()) + Expect(svc.Spec.Ports[0].TargetPort).To(Equal(intstr.FromInt(int(services.RegistryPort)))) + + }) + + It("should properly encode a feature_store.yaml config", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + feast := services.FeastServices{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + } + + deploy := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: feast.GetFeastServiceName(services.RegistryFeastType), + Namespace: resource.Namespace, + }, + deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(1)) + env := getEnvVar(services.FeatureStoreYamlEnvVar, deploy.Spec.Template.Spec.Containers[0].Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err := base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + testConfig := &services.RepoConfig{ + Project: feastProject, + Provider: services.LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + Registry: services.RegistryConfig{ + RegistryType: services.RegistryFileConfigType, + Path: services.LocalRegistryPath, + }, + } + Expect(repoConfig).To(Equal(testConfig)) + + // change feast project and reconcile + resourceNew := resource.DeepCopy() + resourceNew.Spec.FeastProject = "changed" + err = k8sClient.Update(ctx, resourceNew) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + Expect(resource.Spec.FeastProject).To(Equal(resourceNew.Spec.FeastProject)) + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: feast.GetFeastServiceName(services.RegistryFeastType), + Namespace: resource.Namespace, + }, + deploy) + Expect(err).NotTo(HaveOccurred()) + + testConfig.Project = resourceNew.Spec.FeastProject + Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(1)) + env = getEnvVar(services.FeatureStoreYamlEnvVar, deploy.Spec.Template.Spec.Containers[0].Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig).To(Equal(testConfig)) + }) + + It("should error on reconcile", func() { + By("Trying to set the controller OwnerRef of a Deployment that already has a controller") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + feast := services.FeastServices{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + } + + deploy := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: feast.GetFeastServiceName(services.RegistryFeastType), + Namespace: resource.Namespace, + }, + deploy) + Expect(err).NotTo(HaveOccurred()) + + err = controllerutil.RemoveControllerReference(resource, deploy, controllerReconciler.Scheme) + Expect(err).NotTo(HaveOccurred()) + Expect(controllerutil.HasControllerReference(deploy)).To(BeFalse()) + + svc := &corev1.Service{} + name := feast.GetFeastServiceName(services.RegistryFeastType) + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: resource.Namespace, + }, + svc) + Expect(err).NotTo(HaveOccurred()) + err = controllerutil.SetControllerReference(svc, deploy, controllerReconciler.Scheme) + Expect(err).NotTo(HaveOccurred()) + Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + err = k8sClient.Update(ctx, deploy) + Expect(err).NotTo(HaveOccurred()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(HaveOccurred()) + + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + Expect(resource.Status.Conditions).To(HaveLen(3)) + + cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.FailedReason)) + Expect(cond.Message).To(Equal("Error: Object " + resource.Namespace + "/" + name + " is already owned by another Service controller " + name)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.RegistryFailedReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) + Expect(cond.Message).To(Equal("Error: Object " + resource.Namespace + "/" + name + " is already owned by another Service controller " + name)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ClientReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.ClientReadyMessage)) + + Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.FailedPhase)) }) }) }) + +func getEnvVar(name string, envs []corev1.EnvVar) *corev1.EnvVar { + for _, e := range envs { + if e.Name == name { + return &e + } + } + return nil +} diff --git a/infra/feast-operator/internal/controller/services/client.go b/infra/feast-operator/internal/controller/services/client.go new file mode 100644 index 00000000000..a46a7c63390 --- /dev/null +++ b/infra/feast-operator/internal/controller/services/client.go @@ -0,0 +1,79 @@ +/* +Copyright 2024 Feast Community. + +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 services + +import ( + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "gopkg.in/yaml.v3" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +func (feast *FeastServices) deployClient() error { + if err := feast.createClientConfigMap(); err != nil { + return err + } + return nil +} + +func (feast *FeastServices) createClientConfigMap() error { + logger := log.FromContext(feast.Context) + cm := &corev1.ConfigMap{ + ObjectMeta: feast.GetObjectMeta(ClientFeastType), + } + cm.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) + if op, err := controllerutil.CreateOrUpdate(feast.Context, feast.Client, cm, controllerutil.MutateFn(func() error { + return feast.setClientConfigMap(cm) + })); err != nil { + return err + } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { + logger.Info("Successfully reconciled", "ConfigMap", cm.Name, "operation", op) + } + return nil +} + +func (feast *FeastServices) setClientConfigMap(cm *corev1.ConfigMap) error { + cm.Labels = feast.getLabels(ClientFeastType) + clientYaml, err := feast.getClientFeatureStoreYaml() + if err != nil { + return err + } + cm.Data = map[string]string{"feature_store.yaml": string(clientYaml)} + feast.FeatureStore.Status.ClientConfigMap = cm.Name + return controllerutil.SetControllerReference(feast.FeatureStore, cm, feast.Scheme) +} + +func (feast *FeastServices) getClientFeatureStoreYaml() ([]byte, error) { + return yaml.Marshal(feast.getClientRepoConfig()) +} + +func (feast *FeastServices) getClientRepoConfig() RepoConfig { + status := feast.FeatureStore.Status + clientRepoConfig := RepoConfig{ + Project: status.Applied.FeastProject, + Provider: LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + } + if len(status.ServiceUrls.Registry) > 0 { + clientRepoConfig.Registry = RegistryConfig{ + RegistryType: RegistryRemoteConfigType, + Path: status.ServiceUrls.Registry, + } + } + return clientRepoConfig +} diff --git a/infra/feast-operator/internal/controller/services/registry.go b/infra/feast-operator/internal/controller/services/registry.go new file mode 100644 index 00000000000..76e01e67982 --- /dev/null +++ b/infra/feast-operator/internal/controller/services/registry.go @@ -0,0 +1,245 @@ +/* +Copyright 2024 Feast Community. + +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 services + +import ( + "encoding/base64" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// Deploy the feast services +func (feast *FeastServices) Deploy() error { + logger := log.FromContext(feast.Context) + cr := feast.FeatureStore + + if err := feast.deployRegistry(); err != nil { + apimeta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{ + Type: feastdevv1alpha1.RegistryReadyType, + Status: metav1.ConditionFalse, + Reason: feastdevv1alpha1.RegistryFailedReason, + Message: "Error: " + err.Error(), + }) + logger.Error(err, "Error deploying the FeatureStore "+string(RegistryFeastType)+" service") + return err + } else { + apimeta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{ + Type: feastdevv1alpha1.RegistryReadyType, + Status: metav1.ConditionTrue, + Reason: feastdevv1alpha1.ReadyReason, + Message: feastdevv1alpha1.RegistryReadyMessage, + }) + } + + if err := feast.deployClient(); err != nil { + apimeta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{ + Type: feastdevv1alpha1.ClientReadyType, + Status: metav1.ConditionFalse, + Reason: feastdevv1alpha1.ClientFailedReason, + Message: "Error: " + err.Error(), + }) + logger.Error(err, "Error deploying the FeatureStore "+string(ClientFeastType)+" service") + return err + } else { + apimeta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{ + Type: feastdevv1alpha1.ClientReadyType, + Status: metav1.ConditionTrue, + Reason: feastdevv1alpha1.ReadyReason, + Message: feastdevv1alpha1.ClientReadyMessage, + }) + } + return nil +} + +func (feast *FeastServices) deployRegistry() error { + if err := feast.createRegistryDeployment(); err != nil { + return err + } + if err := feast.createRegistryService(); err != nil { + return err + } + return nil +} + +func (feast *FeastServices) createRegistryDeployment() error { + logger := log.FromContext(feast.Context) + deploy := &appsv1.Deployment{ + ObjectMeta: feast.GetObjectMeta(RegistryFeastType), + } + deploy.SetGroupVersionKind(appsv1.SchemeGroupVersion.WithKind("Deployment")) + if op, err := controllerutil.CreateOrUpdate(feast.Context, feast.Client, deploy, controllerutil.MutateFn(func() error { + return feast.setDeployment(deploy, RegistryFeastType) + })); err != nil { + return err + } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { + logger.Info("Successfully reconciled", "Deployment", deploy.Name, "operation", op) + } + + return nil +} + +func (feast *FeastServices) createRegistryService() error { + logger := log.FromContext(feast.Context) + svc := &corev1.Service{ + ObjectMeta: feast.GetObjectMeta(RegistryFeastType), + } + svc.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Service")) + if op, err := controllerutil.CreateOrUpdate(feast.Context, feast.Client, svc, controllerutil.MutateFn(func() error { + return feast.setService(svc, RegistryFeastType) + })); err != nil { + return err + } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { + logger.Info("Successfully reconciled", "Service", svc.Name, "operation", op) + } + return nil +} + +func (feast *FeastServices) setDeployment(deploy *appsv1.Deployment, feastType FeastServiceType) error { + fsYamlB64, err := feast.GetServiceFeatureStoreYamlBase64() + if err != nil { + return err + } + replicas := int32(1) + deploy.Labels = feast.getLabels(feastType) + deploy.Spec = appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: metav1.SetAsLabelSelector(deploy.GetLabels()), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: deploy.GetLabels(), + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: string(feastType), + Image: "feastdev/feature-server:" + feast.FeatureStore.Status.FeastVersion, + ImagePullPolicy: corev1.PullIfNotPresent, + Env: []corev1.EnvVar{ + { + Name: FeatureStoreYamlEnvVar, + Value: fsYamlB64, + }, + }, + }, + }, + }, + }, + } + if feastType == RegistryFeastType { + deploy.Spec.Template.Spec.Containers[0].Command = []string{ + "feast", "serve_registry", + } + deploy.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{ + { + Name: string(feastType), + ContainerPort: RegistryPort, + Protocol: corev1.ProtocolTCP, + }, + } + probeHandler := corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{ + Port: intstr.FromInt(int(RegistryPort)), + }, + } + deploy.Spec.Template.Spec.Containers[0].LivenessProbe = &corev1.Probe{ + ProbeHandler: probeHandler, + InitialDelaySeconds: 30, + PeriodSeconds: 30, + } + deploy.Spec.Template.Spec.Containers[0].ReadinessProbe = &corev1.Probe{ + ProbeHandler: probeHandler, + InitialDelaySeconds: 20, + PeriodSeconds: 10, + } + } + return controllerutil.SetControllerReference(feast.FeatureStore, deploy, feast.Scheme) +} + +func (feast *FeastServices) setService(svc *corev1.Service, feastType FeastServiceType) error { + svc.Labels = feast.getLabels(feastType) + svc.Spec = corev1.ServiceSpec{ + Selector: svc.GetLabels(), + Type: corev1.ServiceTypeClusterIP, + } + if feastType == RegistryFeastType { + svc.Spec.Ports = []corev1.ServicePort{ + { + Name: "http", + Port: int32(80), + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt(int(RegistryPort)), + }, + } + feast.FeatureStore.Status.ServiceUrls.Registry = svc.Name + "." + svc.Namespace + ".svc.cluster.local:80" + } + return controllerutil.SetControllerReference(feast.FeatureStore, svc, feast.Scheme) +} + +// GetObjectMeta returns the feast k8s object metadata +func (feast *FeastServices) GetObjectMeta(feastType FeastServiceType) metav1.ObjectMeta { + return metav1.ObjectMeta{Name: feast.GetFeastServiceName(feastType), Namespace: feast.FeatureStore.Namespace} +} + +func (feast *FeastServices) getLabels(feastType FeastServiceType) map[string]string { + return map[string]string{ + feastdevv1alpha1.GroupVersion.Group + "/name": feast.FeatureStore.Name, + feastdevv1alpha1.GroupVersion.Group + "/service-type": string(feastType), + } +} + +func (feast *FeastServices) getFeastName() string { + return FeastPrefix + feast.FeatureStore.Name +} + +// GetFeastServiceName returns the feast service object name based on service type +func (feast *FeastServices) GetFeastServiceName(feastType FeastServiceType) string { + return feast.getFeastName() + "-" + string(feastType) +} + +// GetServiceFeatureStoreYamlBase64 returns a base64 encoded feature_store.yaml config for the feast service +func (feast *FeastServices) GetServiceFeatureStoreYamlBase64() (string, error) { + fsYaml, err := feast.getServiceFeatureStoreYaml() + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(fsYaml), nil +} + +func (feast *FeastServices) getServiceFeatureStoreYaml() ([]byte, error) { + return yaml.Marshal(feast.getServiceRepoConfig()) +} + +func (feast *FeastServices) getServiceRepoConfig() RepoConfig { + appliedSpec := feast.FeatureStore.Status.Applied + return RepoConfig{ + Project: appliedSpec.FeastProject, + Provider: LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + Registry: RegistryConfig{ + RegistryType: RegistryFileConfigType, + Path: LocalRegistryPath, + }, + } +} diff --git a/infra/feast-operator/internal/controller/services/services_types.go b/infra/feast-operator/internal/controller/services/services_types.go new file mode 100644 index 00000000000..e1a318a3943 --- /dev/null +++ b/infra/feast-operator/internal/controller/services/services_types.go @@ -0,0 +1,72 @@ +/* +Copyright 2024 Feast Community. + +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 services + +import ( + "context" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + FeastPrefix = "feast-" + FeatureStoreYamlEnvVar = "FEATURE_STORE_YAML_BASE64" + RegistryPort = int32(6570) + LocalRegistryPath = "/tmp/registry.db" + + RegistryFeastType FeastServiceType = "registry" + ClientFeastType FeastServiceType = "client" + + RegistryRemoteConfigType RegistryConfigType = "remote" + RegistryFileConfigType RegistryConfigType = "file" + + LocalProviderType FeastProviderType = "local" +) + +// FeastServiceType is the type of feast service +type FeastServiceType string + +// RegistryConfigType provider name or a class name that implements Registry +type RegistryConfigType string + +// FeastProviderType defines an implementation of a feature store object +type FeastProviderType string + +// FeastServices is an interface for configuring and deploying feast services +type FeastServices struct { + client.Client + Context context.Context + Scheme *runtime.Scheme + FeatureStore *feastdevv1alpha1.FeatureStore +} + +// RepoConfig is the Repo config. Typically loaded from feature_store.yaml. +// https://rtd.feast.dev/en/stable/#feast.repo_config.RepoConfig +type RepoConfig struct { + Project string `yaml:"project,omitempty"` + Provider FeastProviderType `yaml:"provider,omitempty"` + Registry RegistryConfig `yaml:"registry,omitempty"` + EntityKeySerializationVersion int `yaml:"entity_key_serialization_version,omitempty"` +} + +// RegistryConfig is the configuration that relates to reading from and writing to the Feast registry. +type RegistryConfig struct { + Path string `yaml:"path,omitempty"` + RegistryType RegistryConfigType `yaml:"registry_type,omitempty"` +} diff --git a/infra/scripts/release/files_to_bump.txt b/infra/scripts/release/files_to_bump.txt index d71f8da37d8..e1731ae8770 100644 --- a/infra/scripts/release/files_to_bump.txt +++ b/infra/scripts/release/files_to_bump.txt @@ -14,5 +14,6 @@ infra/feast-helm-operator/Makefile 6 infra/feast-helm-operator/config/manager/kustomization.yaml 8 infra/feast-operator/Makefile 6 infra/feast-operator/config/manager/kustomization.yaml 8 +infra/feast-operator/api/feastversion/feastversion.go 20 java/pom.xml 38 ui/package.json 3