diff --git a/.secrets.baseline b/.secrets.baseline index 5c0e27d5892..d2838a1317f 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -957,7 +957,7 @@ "filename": "infra/feast-operator/api/v1/featurestore_types.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 942 + "line_number": 946 } ], "infra/feast-operator/api/v1/zz_generated.deepcopy.go": [ @@ -966,21 +966,21 @@ "filename": "infra/feast-operator/api/v1/zz_generated.deepcopy.go", "hashed_secret": "f914fc9324de1bec1ad13dec94a8ea2ddb41fc87", "is_verified": false, - "line_number": 817 + "line_number": 822 }, { "type": "Secret Keyword", "filename": "infra/feast-operator/api/v1/zz_generated.deepcopy.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 878 + "line_number": 883 }, { "type": "Secret Keyword", "filename": "infra/feast-operator/api/v1/zz_generated.deepcopy.go", "hashed_secret": "c2028031c154bbe86fd69bef740855c74b927dcf", "is_verified": false, - "line_number": 1570 + "line_number": 1575 } ], "infra/feast-operator/api/v1alpha1/featurestore_types.go": [ @@ -1564,5 +1564,5 @@ } ] }, - "generated_at": "2026-07-23T08:52:32Z" + "generated_at": "2026-07-30T09:40:48Z" } diff --git a/infra/feast-operator/api/v1/featurestore_types.go b/infra/feast-operator/api/v1/featurestore_types.go index fd8aa832cee..fc75ee16427 100644 --- a/infra/feast-operator/api/v1/featurestore_types.go +++ b/infra/feast-operator/api/v1/featurestore_types.go @@ -407,6 +407,10 @@ type FeatureStoreServices struct { PodAnnotations map[string]string `json:"podAnnotations,omitempty"` // Disable the 'feast repo initialization' initContainer DisableInitContainers bool `json:"disableInitContainers,omitempty"` + // InitImage overrides the image for init containers (feast-init, feast-apply). + // Resolution order: InitImage → RELATED_IMAGE_FEATURE_SERVER → DefaultImage. + // +optional + InitImage *string `json:"initImage,omitempty"` // Runs feast apply on pod start to populate the registry. Defaults to true. Ignored when DisableInitContainers is true. RunFeastApplyOnInit *bool `json:"runFeastApplyOnInit,omitempty"` // Volumes specifies the volumes to mount in the FeatureStore deployment. A corresponding `VolumeMount` should be added to whichever feast service(s) require access to said volume(s). diff --git a/infra/feast-operator/api/v1/zz_generated.deepcopy.go b/infra/feast-operator/api/v1/zz_generated.deepcopy.go index 2a6b6a69266..0c2b53a5e06 100644 --- a/infra/feast-operator/api/v1/zz_generated.deepcopy.go +++ b/infra/feast-operator/api/v1/zz_generated.deepcopy.go @@ -396,6 +396,11 @@ func (in *FeatureStoreServices) DeepCopyInto(out *FeatureStoreServices) { (*out)[key] = val } } + if in.InitImage != nil { + in, out := &in.InitImage, &out.InitImage + *out = new(string) + **out = **in + } if in.RunFeastApplyOnInit != nil { in, out := &in.RunFeastApplyOnInit, &out.RunFeastApplyOnInit *out = new(bool) diff --git a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml index 338dc75bd63..f4b7b8b5240 100644 --- a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml +++ b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml @@ -1723,6 +1723,10 @@ spec: disableInitContainers: description: Disable the 'feast repo initialization' initContainer type: boolean + initImage: + description: InitImage overrides the image for init containers + (feast-init, feast-apply). + type: string offlineStore: description: OfflineStore configures the offline store service properties: @@ -8020,6 +8024,10 @@ spec: disableInitContainers: description: Disable the 'feast repo initialization' initContainer type: boolean + initImage: + description: InitImage overrides the image for init containers + (feast-init, feast-apply). + type: string offlineStore: description: OfflineStore configures the offline store service properties: 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 9cb65a40dad..e433b81b9ae 100644 --- a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml +++ b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml @@ -1723,6 +1723,10 @@ spec: disableInitContainers: description: Disable the 'feast repo initialization' initContainer type: boolean + initImage: + description: InitImage overrides the image for init containers + (feast-init, feast-apply). + type: string offlineStore: description: OfflineStore configures the offline store service properties: @@ -8025,6 +8029,10 @@ spec: disableInitContainers: description: Disable the 'feast repo initialization' initContainer type: boolean + initImage: + description: InitImage overrides the image for init containers + (feast-init, feast-apply). + type: string offlineStore: description: OfflineStore configures the offline store service properties: diff --git a/infra/feast-operator/dist/install.yaml b/infra/feast-operator/dist/install.yaml index 966246f9ad2..17c6210beb0 100644 --- a/infra/feast-operator/dist/install.yaml +++ b/infra/feast-operator/dist/install.yaml @@ -1731,6 +1731,10 @@ spec: disableInitContainers: description: Disable the 'feast repo initialization' initContainer type: boolean + initImage: + description: InitImage overrides the image for init containers + (feast-init, feast-apply). + type: string offlineStore: description: OfflineStore configures the offline store service properties: @@ -8033,6 +8037,10 @@ spec: disableInitContainers: description: Disable the 'feast repo initialization' initContainer type: boolean + initImage: + description: InitImage overrides the image for init containers + (feast-init, feast-apply). + type: string offlineStore: description: OfflineStore configures the offline store service properties: diff --git a/infra/feast-operator/docs/api/markdown/ref.md b/infra/feast-operator/docs/api/markdown/ref.md index 27dcf56a307..840777dfdba 100644 --- a/infra/feast-operator/docs/api/markdown/ref.md +++ b/infra/feast-operator/docs/api/markdown/ref.md @@ -256,6 +256,8 @@ _Appears in:_ This enables annotation-driven integrations like OpenTelemetry auto-instrumentation, Istio sidecar injection, Vault agent injection, etc. | | `disableInitContainers` _boolean_ | Disable the 'feast repo initialization' initContainer | +| `initImage` _string_ | InitImage overrides the image for init containers (feast-init, feast-apply). +Resolution order: InitImage → RELATED_IMAGE_FEATURE_SERVER → DefaultImage. | | `runFeastApplyOnInit` _boolean_ | Runs feast apply on pod start to populate the registry. Defaults to true. Ignored when DisableInitContainers is true. | | `volumes` _[Volume](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#volume-v1-core) array_ | Volumes specifies the volumes to mount in the FeatureStore deployment. A corresponding `VolumeMount` should be added to whichever feast service(s) require access to said volume(s). | | `scaling` _[ScalingConfig](#scalingconfig)_ | Scaling configures horizontal scaling for the FeatureStore deployment (e.g. HPA autoscaling). diff --git a/infra/feast-operator/internal/controller/featurestore_controller_test.go b/infra/feast-operator/internal/controller/featurestore_controller_test.go index a9ac5235eda..712644f7a0c 100644 --- a/infra/feast-operator/internal/controller/featurestore_controller_test.go +++ b/infra/feast-operator/internal/controller/featurestore_controller_test.go @@ -299,6 +299,41 @@ var _ = Describe("FeatureStore Controller", func() { Expect(err).NotTo(HaveOccurred()) Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(2)) Expect(deploy.Spec.Template.Spec.InitContainers[0].Args[0]).To(ContainSubstring("feast init -t spark")) + + // initImage is independent of server images: init containers use initImage, + // main containers keep their own server.image. + initImage := "quay.io/org/feast-init:custom" + serverImage := "quay.io/org/feast-online:server" + if resource.Spec.Services == nil { + resource.Spec.Services = &feastdevv1.FeatureStoreServices{} + } + resource.Spec.Services.InitImage = &initImage + if resource.Spec.Services.OnlineStore == nil { + resource.Spec.Services.OnlineStore = &feastdevv1.OnlineStore{} + } + if resource.Spec.Services.OnlineStore.Server == nil { + resource.Spec.Services.OnlineStore.Server = &feastdevv1.ServerConfigs{} + } + resource.Spec.Services.OnlineStore.Server.Image = &serverImage + err = k8sClient.Update(ctx, resource) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(2)) + Expect(deploy.Spec.Template.Spec.InitContainers[0].Image).To(Equal(initImage)) + Expect(deploy.Spec.Template.Spec.InitContainers[1].Image).To(Equal(initImage)) + online = services.GetOnlineContainer(*deploy) + Expect(online).NotTo(BeNil()) + Expect(online.Image).To(Equal(serverImage)) + Expect(online.Image).NotTo(Equal(initImage)) }) It("should properly encode a feature_store.yaml config", func() { diff --git a/infra/feast-operator/internal/controller/services/services.go b/infra/feast-operator/internal/controller/services/services.go index 93480da719c..764c0f2d9e3 100644 --- a/infra/feast-operator/internal/controller/services/services.go +++ b/infra/feast-operator/internal/controller/services/services.go @@ -704,9 +704,10 @@ func (feast *FeastServices) setInitContainer(podSpec *corev1.PodSpec, fsYamlB64 feastProjectDir := applied.FeastProjectDir workingDir := getOfflineMountPath(feast.Handler.FeatureStore) projectPath := workingDir + "/" + applied.FeastProject + initImage := getInitContainerImage(applied.Services) container := corev1.Container{ Name: feastInitContainerName, - Image: getFeatureServerImage(), + Image: initImage, Env: []corev1.EnvVar{ { Name: TmpFeatureStoreYamlEnvVar, @@ -759,7 +760,7 @@ func (feast *FeastServices) setInitContainer(podSpec *corev1.PodSpec, fsYamlB64 if applied.Services.RunFeastApplyOnInit != nil && *applied.Services.RunFeastApplyOnInit { applyContainer := corev1.Container{ Name: feastApplyContainerName, - Image: getFeatureServerImage(), + Image: initImage, Command: []string{feastCommand, "apply"}, WorkingDir: featureRepoDir, } diff --git a/infra/feast-operator/internal/controller/services/util.go b/infra/feast-operator/internal/controller/services/util.go index 7b04a75183d..e668ac2147d 100644 --- a/infra/feast-operator/internal/controller/services/util.go +++ b/infra/feast-operator/internal/controller/services/util.go @@ -214,6 +214,15 @@ func getFeatureServerImage() string { return DefaultImage } +// getInitContainerImage resolves the image for feast-init / feast-apply. +// Order: spec.services.initImage → RELATED_IMAGE_FEATURE_SERVER → DefaultImage. +func getInitContainerImage(services *feastdevv1.FeatureStoreServices) string { + if services != nil && services.InitImage != nil && len(*services.InitImage) > 0 { + return *services.InitImage + } + return getFeatureServerImage() +} + func checkOfflineStoreFilePersistenceType(value string) error { if slices.Contains(feastdevv1.ValidOfflineStoreFilePersistenceTypes, value) { return nil diff --git a/infra/feast-operator/internal/controller/services/util_test.go b/infra/feast-operator/internal/controller/services/util_test.go index 89c3fa55a72..55cd7118455 100644 --- a/infra/feast-operator/internal/controller/services/util_test.go +++ b/infra/feast-operator/internal/controller/services/util_test.go @@ -17,10 +17,13 @@ limitations under the License. package services import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + "os" + "testing" feastdevv1 "github.com/feast-dev/feast/infra/feast-operator/api/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/utils/ptr" ) var _ = Describe("ApplyDefaultsToStatus", func() { @@ -60,7 +63,7 @@ var _ = Describe("ApplyDefaultsToStatus", func() { }) // #6586: disabling the online store opts out of its persistence and serving - // pod, letting a registry-only or offline-only FeatureStore skip it while + // pod, letting a registry-only or offline-only ViewerStore skip it while // leaving the default-on behavior unchanged for everyone else. It("does not apply persistence or server defaults when the online store is disabled", func() { cr := &feastdevv1.FeatureStore{ @@ -81,3 +84,62 @@ var _ = Describe("ApplyDefaultsToStatus", func() { Expect(online.Server).To(BeNil()) }) }) + +func TestGetInitContainerImage(t *testing.T) { + customInit := "quay.io/org/feast-init:custom" + envImage := "quay.io/org/feast-env:test" + + t.Run("uses initImage and ignores differing server images", func(t *testing.T) { + t.Setenv(feastServerImageVar, envImage) + got := getInitContainerImage(&feastdevv1.FeatureStoreServices{ + InitImage: ptr.To(customInit), + OfflineStore: &feastdevv1.OfflineStore{ + Server: &feastdevv1.ServerConfigs{ + ContainerConfigs: feastdevv1.ContainerConfigs{ + DefaultCtrConfigs: feastdevv1.DefaultCtrConfigs{ + Image: ptr.To("quay.io/org/offline:v1"), + }, + }, + }, + }, + OnlineStore: &feastdevv1.OnlineStore{ + Server: &feastdevv1.ServerConfigs{ + ContainerConfigs: feastdevv1.ContainerConfigs{ + DefaultCtrConfigs: feastdevv1.DefaultCtrConfigs{ + Image: ptr.To("quay.io/org/online:v1"), + }, + }, + }, + }, + }) + if got != customInit { + t.Fatalf("got %q, want %q (must not inherit server images)", got, customInit) + } + }) + + t.Run("falls back to RELATED_IMAGE_FEATURE_SERVER", func(t *testing.T) { + t.Setenv(feastServerImageVar, envImage) + got := getInitContainerImage(&feastdevv1.FeatureStoreServices{}) + if got != envImage { + t.Fatalf("got %q, want %q", got, envImage) + } + }) + + t.Run("falls back to DefaultImage", func(t *testing.T) { + _ = os.Unsetenv(feastServerImageVar) + got := getInitContainerImage(nil) + if got != DefaultImage { + t.Fatalf("got %q, want %q", got, DefaultImage) + } + }) + + t.Run("ignores empty initImage", func(t *testing.T) { + t.Setenv(feastServerImageVar, envImage) + got := getInitContainerImage(&feastdevv1.FeatureStoreServices{ + InitImage: ptr.To(""), + }) + if got != envImage { + t.Fatalf("got %q, want %q", got, envImage) + } + }) +}