diff --git a/.secrets.baseline b/.secrets.baseline index a0536178ad8..62d7fd33f37 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": 946 + "line_number": 958 } ], "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": 822 + "line_number": 842 }, { "type": "Secret Keyword", "filename": "infra/feast-operator/api/v1/zz_generated.deepcopy.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 883 + "line_number": 903 }, { "type": "Secret Keyword", "filename": "infra/feast-operator/api/v1/zz_generated.deepcopy.go", "hashed_secret": "c2028031c154bbe86fd69bef740855c74b927dcf", "is_verified": false, - "line_number": 1575 + "line_number": 1595 } ], "infra/feast-operator/api/v1alpha1/featurestore_types.go": [ @@ -989,7 +989,7 @@ "filename": "infra/feast-operator/api/v1alpha1/featurestore_types.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 651 + "line_number": 663 } ], "infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go": [ @@ -998,21 +998,21 @@ "filename": "infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go", "hashed_secret": "f914fc9324de1bec1ad13dec94a8ea2ddb41fc87", "is_verified": false, - "line_number": 595 + "line_number": 615 }, { "type": "Secret Keyword", "filename": "infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 1103 + "line_number": 1123 }, { "type": "Secret Keyword", "filename": "infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go", "hashed_secret": "c2028031c154bbe86fd69bef740855c74b927dcf", "is_verified": false, - "line_number": 1108 + "line_number": 1128 } ], "infra/feast-operator/bundle/manifests/openlineage-secret_v1_secret.yaml": [ @@ -1181,7 +1181,7 @@ "filename": "infra/feast-operator/internal/controller/services/services.go", "hashed_secret": "36dc326eb15c7bdd8d91a6b87905bcea20b637d1", "is_verified": false, - "line_number": 183 + "line_number": 184 } ], "infra/feast-operator/internal/controller/services/tls_test.go": [ @@ -1564,5 +1564,5 @@ } ] }, - "generated_at": "2026-07-30T16:22:49Z" + "generated_at": "2026-07-31T05:29:18Z" } diff --git a/docs/how-to-guides/feast-operator/01-project-provisioning.md b/docs/how-to-guides/feast-operator/01-project-provisioning.md index b54ce57eeeb..787ef173403 100644 --- a/docs/how-to-guides/feast-operator/01-project-provisioning.md +++ b/docs/how-to-guides/feast-operator/01-project-provisioning.md @@ -2,7 +2,8 @@ The operator needs a Feast feature repository (a directory containing `feature_store.yaml` and Python feature-view definitions) to work from. `spec.feastProjectDir` controls how that -directory is created inside the pods. Exactly one of `git` or `init` must be set. +directory is created inside the pods. When `feastProjectDir` is specified, exactly one of +`git`, `init`, or `packaged` must be set. --- @@ -89,7 +90,7 @@ feastProjectDir: ### Full `git` field reference | Field | Type | Description | -|-------|------|-------------| +| ------- | ------ | ------------- | | `url` | string | Repository URL (HTTPS or SSH) | | `ref` | string | Branch, tag, or commit SHA. Defaults to the remote HEAD | | `featureRepoPath` | string | Relative path within the repo to the feature repository directory. Default: `feature_repo` | @@ -151,9 +152,90 @@ feastProjectDir: --- +## Option C — Use a repository packaged in an image (`feastProjectDir.packaged`) + +Use `packaged` when the feature repository is built into a feature-server image. This is +useful in air-gapped environments and in release workflows where feature definitions and +their Python dependencies are promoted together as an immutable image. + +```yaml +apiVersion: feast.dev/v1 +kind: FeatureStore +metadata: + name: packaged-feature-store +spec: + feastProject: credit_scoring + feastProjectDir: + packaged: + image: registry.example.com/feature-server@sha256:0123456789abcdef + featureRepoPath: /opt/feast/feature_repo +``` + +The repository can be added to a Feast feature-server image with a Dockerfile such as: + +```dockerfile +FROM quay.io/feastdev/feature-server:latest +COPY feature_repo/ /opt/feast/feature_repo/ +``` + +`featureRepoPath` must be a canonical absolute, non-root path: do not use `.`, `..`, +repeated separators, or a trailing separator. Put it outside operator-mounted locations +such as `/feast-data`; a volume mounted there would hide files baked into the image. When +init containers are enabled, the packaged path also must not equal, contain, or be +contained by the staged repository path. + +With init containers enabled (the default), each Pod starts in this order: + +1. `feast-init` replaces the operator-managed staged repository with a fresh copy of the + repository from `packaged.featureRepoPath`. With the default storage configuration, + for example, it copies `/opt/feast/feature_repo` from the image to + `/feast-data//feature_repo`. +2. In the staged copy only, `feast-init` replaces `feature_store.yaml` (if exists in the + baked image) with the configuration generated from the FeatureStore resource. The file + baked into the image is not modified. The Python feature definitions come from the + packaged repository, while the FeatureStore resource remains authoritative for runtime + configuration. +3. When `services.runFeastApplyOnInit` is omitted or `true` (the default), `feast-apply` + runs `feast apply` from the staged repository using the packaged image. Setting it to + `false` skips only this step; repository staging still occurs. +4. The Feast service containers start with the staged repository as their working + directory. + +The repository baked into the image is therefore the source artifact, while the staged +repository is the runtime copy used by `feast apply` and the Feast services. + +For a baked repository whose own `feature_store.yaml` must remain authoritative, disable +init containers: + +```yaml +services: + disableInitContainers: true +``` + +In that mode, Feast service containers use `featureRepoPath` directly and neither staging +nor `feast apply` runs during pod initialization. The Operator does not update the registry, +so `feast apply` must be handled separately—for example, by CI/CD or a separately managed +Kubernetes Job or CronJob—whenever the packaged feature definitions change. + +The packaged `image` is optional. When set, it is the default for repository initialization, +`feast apply`, and Feast services. `services.initImage` takes precedence for the +`feast-init` and `feast-apply` init containers, while an explicit image on an individual +service takes precedence for that service. When the packaged image is omitted, the operator +uses `RELATED_IMAGE_FEATURE_SERVER` or its built-in feature-server image fallback. + +### Full `packaged` field reference + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `featureRepoPath` | string | yes | Canonical absolute, non-root path to the feature repository in the image; it must not overlap the staged repository path | +| `image` | string | no | Image containing the repository; defaults to the operator feature-server image | + +--- + ## `feast apply` on startup -By default, when the init container completes (git clone or `feast init`), the operator runs +By default, when repository initialization completes (git clone, `feast init`, or packaged +repository staging), the operator runs `feast apply` before starting the servers. This registers all feature definitions with the registry. @@ -175,9 +257,8 @@ services: ## When `feastProjectDir` is omitted -If neither `git` nor `init` is set, the operator mounts an empty directory. In this case -you must supply a `feature_store.yaml` through another mechanism (e.g. a ConfigMap volume -mount via `services.volumes` + `volumeMounts`). +If `feastProjectDir` is not set, the operator defaults to `feastProjectDir.init: {}` and +creates a local template repository. --- @@ -188,3 +269,4 @@ mount via `services.volumes` + `volumeMounts`). - [Sample: private git repo with token](https://github.com/feast-dev/feast/blob/stable/infra/feast-operator/config/samples/v1_featurestore_git_token.yaml) - [Sample: monorepo with featureRepoPath](https://github.com/feast-dev/feast/blob/stable/infra/feast-operator/config/samples/v1_featurestore_git_repopath.yaml) - [Sample: feast init](https://github.com/feast-dev/feast/blob/stable/infra/feast-operator/config/samples/v1_featurestore_init.yaml) +- [Sample: packaged feature repository](https://github.com/feast-dev/feast/blob/stable/infra/feast-operator/config/samples/v1_featurestore_packaged.yaml) diff --git a/docs/how-to-guides/feast-operator/README.md b/docs/how-to-guides/feast-operator/README.md index 26e515f309f..25d71c130c7 100644 --- a/docs/how-to-guides/feast-operator/README.md +++ b/docs/how-to-guides/feast-operator/README.md @@ -23,7 +23,7 @@ look for store-specific YAML options in the Feast SDK docs. | # | Guide | Topic | |---|-------|-------| -| 1 | [Project Provisioning](01-project-provisioning.md) | `feastProjectDir`: cloning a git repo vs `feast init` templates | +| 1 | [Project Provisioning](01-project-provisioning.md) | `feastProjectDir`: git clone, `feast init`, or a repository packaged in an image | | 2 | [Persistence](02-persistence.md) | File (path + PVC) vs DB store for offline/online/registry; Secret format | | 3 | [Serving & Observability](03-serving-and-observability.md) | Feature server workers, log level, Prometheus metrics, offline push batching, MCP | | 4 | [Registry Topology](04-registry-topology.md) | Local vs remote registry, cross-namespace `feastRef`, remote TLS | diff --git a/docs/how-to-guides/production-deployment-topologies.md b/docs/how-to-guides/production-deployment-topologies.md index ee8bb49be54..52dc2f3873b 100644 --- a/docs/how-to-guides/production-deployment-topologies.md +++ b/docs/how-to-guides/production-deployment-topologies.md @@ -1066,12 +1066,15 @@ Production environments in regulated industries (finance, government, defense) o ### Default init container behavior -When `feastProjectDir` is set on the FeatureStore CR, the operator creates up to two init containers: +When `feastProjectDir` is set on the FeatureStore CR, the operator creates up to two init containers unless `services.disableInitContainers` is `true`: -1. **`feast-init`** — bootstraps the feature repository by running either `git clone` (if `feastProjectDir.git` is set) or `feast init` (if `feastProjectDir.init` is set), then writes the generated `feature_store.yaml` into the repo directory. +1. **`feast-init`** — bootstraps the feature repository by running `git clone`, `feast init`, or copying a repository from `feastProjectDir.packaged.featureRepoPath`. It then writes the operator-generated `feature_store.yaml` into the initialized repository. 2. **`feast-apply`** — runs `feast apply` to register feature definitions in the registry. Controlled by `runFeastApplyOnInit` (defaults to `true`). Skipped when `disableInitContainers` is `true`. -In air-gapped environments, `git clone` will fail because the cluster cannot reach external Git repositories. The solution is to **pre-bake** the feature repository into a custom container image and disable the init containers entirely. +In air-gapped environments, use `feastProjectDir.packaged` to identify a feature repository baked into an image. The operator supports two lifecycle modes: + +* Keep init containers enabled to refresh shared storage from the image, generate configuration from the FeatureStore CR, and optionally run `feast apply`. +* Set `services.disableInitContainers: true` to run directly from the baked path and treat its `feature_store.yaml` as authoritative. ### Air-gapped deployment workflow @@ -1086,12 +1089,12 @@ graph TD end subgraph InternalRegistry["Internal Container Registry"] - Mirror["registry.internal.example.com
/feast/feature-server:v0.61"] + Mirror["registry.internal.example.com
/feast/feature-server:release"] end subgraph AirGappedCluster["Air-Gapped Kubernetes Cluster"] SA["ServiceAccount
(imagePullSecrets)"] - CR["FeatureStore CR
disableInitContainers: true
image: registry.internal..."] + CR["FeatureStore CR
feastProjectDir.packaged
disableInitContainers: true"] Deploy["Feast Deployment
(no init containers)"] SA --> Deploy CR --> Deploy @@ -1105,8 +1108,8 @@ graph TD 1. **Build a custom container image** that bundles the feature repository and all Python dependencies into the Feast base image. 2. **Push** the image to your internal container registry. -3. **Set `services.disableInitContainers: true`** on the FeatureStore CR to skip `git clone` / `feast init` and `feast apply`. -4. **Override the image** on each service using the per-service `image` field. +3. **Configure `feastProjectDir.packaged`** with the image and the canonical absolute path to the bundled repository. Do not use `.`, `..`, repeated separators, or a trailing separator, and keep the path outside operator-mounted locations such as `/feast-data` so it cannot overlap the staged repository. +4. **Choose the lifecycle:** leave init containers enabled for operator-managed configuration and `feast apply`, or set `services.disableInitContainers: true` to use the baked repository and configuration directly. 5. **Set `imagePullPolicy: IfNotPresent`** (or `Never` if images are pre-loaded on nodes). 6. **Configure `imagePullSecrets`** on the namespace's ServiceAccount — the FeatureStore CRD does not expose an `imagePullSecrets` field, so use the standard Kubernetes approach of attaching secrets to the ServiceAccount that the pods run under. @@ -1119,6 +1122,10 @@ metadata: name: airgap-production spec: feastProject: my_project + feastProjectDir: + packaged: + image: registry.internal.example.com/feast/feature-server:release + featureRepoPath: /opt/feast/feature_repo services: disableInitContainers: true onlineStore: @@ -1128,7 +1135,6 @@ spec: secretRef: name: feast-online-store server: - image: registry.internal.example.com/feast/feature-server:v0.61 imagePullPolicy: IfNotPresent resources: requests: @@ -1145,10 +1151,14 @@ spec: secretRef: name: feast-registry-store server: - image: registry.internal.example.com/feast/feature-server:v0.61 imagePullPolicy: IfNotPresent ``` +The packaged image is the default for every Feast service and for the `feast-init` and +`feast-apply` init containers. A per-service `image` still takes precedence for that +service, and `services.initImage` takes precedence for both init containers. Remove +`disableInitContainers: true` to use operator-managed staging and startup apply instead. + {% hint style="info" %} **Pre-populating the registry:** With init containers disabled, `feast apply` does not run on pod startup. You can populate the registry by: diff --git a/infra/feast-operator/README.md b/infra/feast-operator/README.md index c639be54fde..f879dff1cc1 100644 --- a/infra/feast-operator/README.md +++ b/infra/feast-operator/README.md @@ -7,7 +7,7 @@ This is a K8s Operator that can be used to deploy and manage **Feast**, an open | Guide | Topic | |-------|-------| -| [1 — Project Provisioning](https://docs.feast.dev/how-to-guides/feast-operator/01-project-provisioning) | `feastProjectDir`: git clone vs `feast init` templates | +| [1 — Project Provisioning](https://docs.feast.dev/how-to-guides/feast-operator/01-project-provisioning) | `feastProjectDir`: git clone, `feast init`, or a repository packaged in an image | | [2 — Persistence](https://docs.feast.dev/how-to-guides/feast-operator/02-persistence) | File (path + PVC) vs DB store for offline/online/registry; Secret format | | [3 — Serving & Observability](https://docs.feast.dev/how-to-guides/feast-operator/03-serving-and-observability) | Workers, log level, Prometheus metrics, offline push batching, MCP | | [4 — Registry Topology](https://docs.feast.dev/how-to-guides/feast-operator/04-registry-topology) | Local, remote, cross-namespace `feastRef` | diff --git a/infra/feast-operator/api/v1/featurestore_types.go b/infra/feast-operator/api/v1/featurestore_types.go index fc75ee16427..3372e74f63a 100644 --- a/infra/feast-operator/api/v1/featurestore_types.go +++ b/infra/feast-operator/api/v1/featurestore_types.go @@ -175,10 +175,22 @@ type FeatureStoreSpec struct { } // FeastProjectDir defines how to create the feast project directory. -// +kubebuilder:validation:XValidation:rule="[has(self.git), has(self.init)].exists_one(c, c)",message="One selection required between init or git." +// +kubebuilder:validation:XValidation:rule="[has(self.git), has(self.init), has(self.packaged)].exists_one(c, c)",message="One selection required between init, git, or packaged." type FeastProjectDir struct { - Git *GitCloneOptions `json:"git,omitempty"` - Init *FeastInitOptions `json:"init,omitempty"` + Git *GitCloneOptions `json:"git,omitempty"` + Init *FeastInitOptions `json:"init,omitempty"` + Packaged *FeastPackagedOptions `json:"packaged,omitempty"` +} + +// FeastPackagedOptions describes a feature repository packaged in a feature server image. +// +kubebuilder:validation:XValidation:rule="self.featureRepoPath.startsWith('/') && self.featureRepoPath != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..')",message="FeatureRepoPath must be a canonical absolute, non-root path without dot segments or repeated separators." +type FeastPackagedOptions struct { + // Image containing the packaged feature repository. When set, this image is used by the + // repository initialization and feast apply containers and as the default service image. + // When omitted, the operator's configured feature server image is used. + Image string `json:"image,omitempty"` + // FeatureRepoPath is the canonical absolute path to the feature repository in the image. + FeatureRepoPath string `json:"featureRepoPath"` } // GitCloneOptions describes how a clone should be performed. @@ -408,7 +420,7 @@ type FeatureStoreServices struct { // 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. + // Resolution order: InitImage → FeastProjectDir.Packaged.Image → 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. diff --git a/infra/feast-operator/api/v1/zz_generated.deepcopy.go b/infra/feast-operator/api/v1/zz_generated.deepcopy.go index 0c2b53a5e06..3035ed066cf 100644 --- a/infra/feast-operator/api/v1/zz_generated.deepcopy.go +++ b/infra/feast-operator/api/v1/zz_generated.deepcopy.go @@ -257,6 +257,21 @@ func (in *FeastInitOptions) DeepCopy() *FeastInitOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FeastPackagedOptions) DeepCopyInto(out *FeastPackagedOptions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeastPackagedOptions. +func (in *FeastPackagedOptions) DeepCopy() *FeastPackagedOptions { + if in == nil { + return nil + } + out := new(FeastPackagedOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FeastProjectDir) DeepCopyInto(out *FeastProjectDir) { *out = *in @@ -270,6 +285,11 @@ func (in *FeastProjectDir) DeepCopyInto(out *FeastProjectDir) { *out = new(FeastInitOptions) **out = **in } + if in.Packaged != nil { + in, out := &in.Packaged, &out.Packaged + *out = new(FeastPackagedOptions) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeastProjectDir. diff --git a/infra/feast-operator/api/v1alpha1/featurestore_types.go b/infra/feast-operator/api/v1alpha1/featurestore_types.go index 11e201dd0d6..8ccde377e77 100644 --- a/infra/feast-operator/api/v1alpha1/featurestore_types.go +++ b/infra/feast-operator/api/v1alpha1/featurestore_types.go @@ -78,10 +78,22 @@ type FeatureStoreSpec struct { } // FeastProjectDir defines how to create the feast project directory. -// +kubebuilder:validation:XValidation:rule="[has(self.git), has(self.init)].exists_one(c, c)",message="One selection required between init or git." +// +kubebuilder:validation:XValidation:rule="[has(self.git), has(self.init), has(self.packaged)].exists_one(c, c)",message="One selection required between init, git, or packaged." type FeastProjectDir struct { - Git *GitCloneOptions `json:"git,omitempty"` - Init *FeastInitOptions `json:"init,omitempty"` + Git *GitCloneOptions `json:"git,omitempty"` + Init *FeastInitOptions `json:"init,omitempty"` + Packaged *FeastPackagedOptions `json:"packaged,omitempty"` +} + +// FeastPackagedOptions describes a feature repository packaged in a feature server image. +// +kubebuilder:validation:XValidation:rule="self.featureRepoPath.startsWith('/') && self.featureRepoPath != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..')",message="FeatureRepoPath must be a canonical absolute, non-root path without dot segments or repeated separators." +type FeastPackagedOptions struct { + // Image containing the packaged feature repository. When set, this image is used by the + // repository initialization and feast apply containers and as the default service image. + // When omitted, the operator's configured feature server image is used. + Image string `json:"image,omitempty"` + // FeatureRepoPath is the canonical absolute path to the feature repository in the image. + FeatureRepoPath string `json:"featureRepoPath"` } // GitCloneOptions describes how a clone should be performed. diff --git a/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go b/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go index 4033c368c8b..17ae4841966 100644 --- a/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -183,6 +183,21 @@ func (in *FeastInitOptions) DeepCopy() *FeastInitOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FeastPackagedOptions) DeepCopyInto(out *FeastPackagedOptions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeastPackagedOptions. +func (in *FeastPackagedOptions) DeepCopy() *FeastPackagedOptions { + if in == nil { + return nil + } + out := new(FeastPackagedOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FeastProjectDir) DeepCopyInto(out *FeastProjectDir) { *out = *in @@ -196,6 +211,11 @@ func (in *FeastProjectDir) DeepCopyInto(out *FeastProjectDir) { *out = new(FeastInitOptions) **out = **in } + if in.Packaged != nil { + in, out := &in.Packaged, &out.Packaged + *out = new(FeastPackagedOptions) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeastProjectDir. diff --git a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml index f4b7b8b5240..0ab08afef51 100644 --- a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml +++ b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml @@ -810,10 +810,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute path + to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' materialization: description: |- Materialization controls feature materialization behavior (batch size, pull strategy). @@ -2262,6 +2284,11 @@ spec: onlineStore: description: OnlineStore configures the online store service properties: + disabled: + description: |- + Disabled skips deploying the online store service entirely, including its + serving pod and persistence. + type: boolean persistence: description: OnlineStorePersistence configures the persistence settings for the online store service @@ -7103,10 +7130,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute + path to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' materialization: description: |- Materialization controls feature materialization behavior (batch size, pull strategy). @@ -8570,6 +8619,11 @@ spec: onlineStore: description: OnlineStore configures the online store service properties: + disabled: + description: |- + Disabled skips deploying the online store service entirely, including its + serving pod and persistence. + type: boolean persistence: description: OnlineStorePersistence configures the persistence settings for the online store service @@ -13523,10 +13577,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute path + to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' services: description: FeatureStoreServices defines the desired feast services. An ephemeral onlineStore feature server is deployed by default. @@ -18020,10 +18096,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute + path to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' services: description: FeatureStoreServices defines the desired feast services. An ephemeral onlineStore feature server is deployed by default. 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 e433b81b9ae..8184906e14d 100644 --- a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml +++ b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml @@ -810,10 +810,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute path + to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' materialization: description: |- Materialization controls feature materialization behavior (batch size, pull strategy). @@ -7108,10 +7130,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute + path to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' materialization: description: |- Materialization controls feature materialization behavior (batch size, pull strategy). @@ -13533,10 +13577,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute path + to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' services: description: FeatureStoreServices defines the desired feast services. An ephemeral onlineStore feature server is deployed by default. @@ -18030,10 +18096,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute + path to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' services: description: FeatureStoreServices defines the desired feast services. An ephemeral onlineStore feature server is deployed by default. diff --git a/infra/feast-operator/config/samples/v1_featurestore_packaged.yaml b/infra/feast-operator/config/samples/v1_featurestore_packaged.yaml new file mode 100644 index 00000000000..4b334b9d3d8 --- /dev/null +++ b/infra/feast-operator/config/samples/v1_featurestore_packaged.yaml @@ -0,0 +1,10 @@ +apiVersion: feast.dev/v1 +kind: FeatureStore +metadata: + name: sample-packaged +spec: + feastProject: sample_packaged + feastProjectDir: + packaged: + image: registry.example.com/feature-server@sha256:0123456789abcdef + featureRepoPath: /opt/feast/feature_repo diff --git a/infra/feast-operator/dist/install.yaml b/infra/feast-operator/dist/install.yaml index 17c6210beb0..be85a29a7b2 100644 --- a/infra/feast-operator/dist/install.yaml +++ b/infra/feast-operator/dist/install.yaml @@ -818,10 +818,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute path + to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' materialization: description: |- Materialization controls feature materialization behavior (batch size, pull strategy). @@ -7116,10 +7138,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute + path to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' materialization: description: |- Materialization controls feature materialization behavior (batch size, pull strategy). @@ -13541,10 +13585,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute path + to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' services: description: FeatureStoreServices defines the desired feast services. An ephemeral onlineStore feature server is deployed by default. @@ -18038,10 +18104,32 @@ spec: - pytorch_nlp type: string type: object + packaged: + description: FeastPackagedOptions describes a feature repository + packaged in a feature server image. + properties: + featureRepoPath: + description: FeatureRepoPath is the canonical absolute + path to the feature repository in the image. + type: string + image: + description: Image containing the packaged feature repository. + type: string + required: + - featureRepoPath + type: object + x-kubernetes-validations: + - message: FeatureRepoPath must be a canonical absolute, non-root + path without dot segments or repeated separators. + rule: self.featureRepoPath.startsWith('/') && self.featureRepoPath + != '/' && !self.featureRepoPath.contains('//') && !self.featureRepoPath.endsWith('/') + && !self.featureRepoPath.contains('/./') && !self.featureRepoPath.endsWith('/.') + && !self.featureRepoPath.contains('/../') && !self.featureRepoPath.endsWith('/..') type: object x-kubernetes-validations: - - message: One selection required between init or git. - rule: '[has(self.git), has(self.init)].exists_one(c, c)' + - message: One selection required between init, git, or packaged. + rule: '[has(self.git), has(self.init), has(self.packaged)].exists_one(c, + c)' services: description: FeatureStoreServices defines the desired feast services. An ephemeral onlineStore feature server is deployed by default. diff --git a/infra/feast-operator/docs/api/markdown/ref.md b/infra/feast-operator/docs/api/markdown/ref.md index 840777dfdba..cb911ffae22 100644 --- a/infra/feast-operator/docs/api/markdown/ref.md +++ b/infra/feast-operator/docs/api/markdown/ref.md @@ -188,6 +188,23 @@ _Appears in:_ | `template` _string_ | Template for the created project | +#### FeastPackagedOptions + + + +FeastPackagedOptions describes a feature repository packaged in a feature server image. + +_Appears in:_ +- [FeastProjectDir](#feastprojectdir) + +| Field | Description | +| --- | --- | +| `image` _string_ | Image containing the packaged feature repository. When set, this image is used by the +repository initialization and feast apply containers and as the default service image. +When omitted, the operator's configured feature server image is used. | +| `featureRepoPath` _string_ | FeatureRepoPath is the canonical absolute path to the feature repository in the image. | + + #### FeastProjectDir @@ -201,6 +218,7 @@ _Appears in:_ | --- | --- | | `git` _[GitCloneOptions](#gitcloneoptions)_ | | | `init` _[FeastInitOptions](#feastinitoptions)_ | | +| `packaged` _[FeastPackagedOptions](#feastpackagedoptions)_ | | #### FeatureStore @@ -257,7 +275,7 @@ This enables annotation-driven integrations like OpenTelemetry auto-instrumentat 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. | +Resolution order: InitImage → FeastProjectDir.Packaged.Image → 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_packaged_test.go b/infra/feast-operator/internal/controller/featurestore_controller_packaged_test.go new file mode 100644 index 00000000000..9c534a624bd --- /dev/null +++ b/infra/feast-operator/internal/controller/featurestore_controller_packaged_test.go @@ -0,0 +1,237 @@ +/* +Copyright 2026 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 controller + +import ( + "context" + + feastdevv1 "github.com/feast-dev/feast/infra/feast-operator/api/v1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +var _ = Describe("Packaged feature repositories", func() { + const ( + resourceName = "packaged-feature-repo" + packagedImage = "registry.example.com/feature-server@sha256:0123456789abcdef" + packagedRepoDir = "/opt/feast/feature_repo" + ) + + ctx := context.Background() + key := types.NamespacedName{Name: resourceName, Namespace: "default"} + + newFeatureStore := func() *feastdevv1.FeatureStore { + return &feastdevv1.FeatureStore{ + ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, + Spec: feastdevv1.FeatureStoreSpec{ + FeastProject: feastProject, + FeastProjectDir: &feastdevv1.FeastProjectDir{ + Packaged: &feastdevv1.FeastPackagedOptions{ + Image: packagedImage, + FeatureRepoPath: packagedRepoDir, + }, + }, + }, + } + } + + reconcileFeatureStore := func() (*feastdevv1.FeatureStore, *appsv1.Deployment) { + reconciler := &FeatureStoreReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: key}) + Expect(err).NotTo(HaveOccurred()) + + featureStore := &feastdevv1.FeatureStore{} + Expect(k8sClient.Get(ctx, key, featureStore)).To(Succeed()) + feastServices := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: k8sClient, + Context: ctx, + Scheme: k8sClient.Scheme(), + FeatureStore: featureStore, + }, + } + deployment := &appsv1.Deployment{} + meta := feastServices.GetObjectMeta() + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: meta.Name, Namespace: meta.Namespace}, deployment)).To(Succeed()) + return featureStore, deployment + } + + BeforeEach(func() { + Expect(k8sClient.Create(ctx, newFeatureStore())).To(Succeed()) + }) + + AfterEach(func() { + featureStore := &feastdevv1.FeatureStore{} + Expect(k8sClient.Get(ctx, key, featureStore)).To(Succeed()) + Expect(k8sClient.Delete(ctx, featureStore)).To(Succeed()) + }) + + It("stages the packaged repository and applies it from the shared directory", func() { + featureStore, deployment := reconcileFeatureStore() + + canonicalRepoDir := services.EphemeralPath + "/" + feastProject + "/" + services.FeatureRepoDir + Expect(deployment.Spec.Template.Spec.InitContainers).To(HaveLen(2)) + initContainer := deployment.Spec.Template.Spec.InitContainers[0] + Expect(initContainer.Name).To(Equal("feast-init")) + Expect(initContainer.Image).To(Equal(packagedImage)) + Expect(initContainer.WorkingDir).To(Equal(services.EphemeralPath)) + Expect(initContainer.Env).To(ContainElements( + corev1.EnvVar{Name: "FEAST_PACKAGED_FEATURE_REPO_PATH", Value: packagedRepoDir}, + corev1.EnvVar{Name: "FEAST_STAGED_FEATURE_REPO_PATH", Value: canonicalRepoDir}, + )) + Expect(initContainer.Args).To(HaveLen(1)) + Expect(initContainer.Args[0]).To(ContainSubstring(`rm -rf -- "${FEAST_STAGED_FEATURE_REPO_PATH}"`)) + Expect(initContainer.Args[0]).To(ContainSubstring(`cp -a -- "${FEAST_PACKAGED_FEATURE_REPO_PATH}/." "${FEAST_STAGED_FEATURE_REPO_PATH}/"`)) + Expect(initContainer.Args[0]).To(ContainSubstring(`printf '%s' "${TMP_FEATURE_STORE_YAML_BASE64}" | base64 -d`)) + Expect(initContainer.Args[0]).To(ContainSubstring(`"${FEAST_STAGED_FEATURE_REPO_PATH}/feature_store.yaml"`)) + + applyContainer := deployment.Spec.Template.Spec.InitContainers[1] + Expect(applyContainer.Name).To(Equal("feast-apply")) + Expect(applyContainer.Image).To(Equal(packagedImage)) + Expect(applyContainer.Command).To(Equal([]string{"feast", "apply"})) + Expect(applyContainer.WorkingDir).To(Equal(canonicalRepoDir)) + + online := services.GetOnlineContainer(*deployment) + Expect(online.Image).To(Equal(packagedImage)) + Expect(online.WorkingDir).To(Equal(canonicalRepoDir)) + Expect(*featureStore.Status.Applied.Services.OnlineStore.Server.Image).To(Equal(packagedImage)) + }) + + It("supports staging without applying and direct use of the baked repository", func() { + featureStore := &feastdevv1.FeatureStore{} + Expect(k8sClient.Get(ctx, key, featureStore)).To(Succeed()) + featureStore.Spec.Services = &feastdevv1.FeatureStoreServices{RunFeastApplyOnInit: ptr(false)} + Expect(k8sClient.Update(ctx, featureStore)).To(Succeed()) + + featureStore, deployment := reconcileFeatureStore() + Expect(deployment.Spec.Template.Spec.InitContainers).To(HaveLen(1)) + Expect(deployment.Spec.Template.Spec.InitContainers[0].Name).To(Equal("feast-init")) + + featureStore.Spec.Services.DisableInitContainers = true + Expect(k8sClient.Update(ctx, featureStore)).To(Succeed()) + _, deployment = reconcileFeatureStore() + Expect(deployment.Spec.Template.Spec.InitContainers).To(BeEmpty()) + online := services.GetOnlineContainer(*deployment) + Expect(online.Image).To(Equal(packagedImage)) + Expect(online.WorkingDir).To(Equal(packagedRepoDir)) + }) + + It("keeps explicit service images ahead of the packaged image", func() { + const serviceImage = "registry.example.com/online-server:custom" + featureStore := &feastdevv1.FeatureStore{} + Expect(k8sClient.Get(ctx, key, featureStore)).To(Succeed()) + featureStore.Spec.Services = &feastdevv1.FeatureStoreServices{ + OnlineStore: &feastdevv1.OnlineStore{ + Server: &feastdevv1.ServerConfigs{ + ContainerConfigs: feastdevv1.ContainerConfigs{ + DefaultCtrConfigs: feastdevv1.DefaultCtrConfigs{Image: ptr(serviceImage)}, + }, + }, + }, + } + Expect(k8sClient.Update(ctx, featureStore)).To(Succeed()) + + _, deployment := reconcileFeatureStore() + Expect(deployment.Spec.Template.Spec.InitContainers[0].Image).To(Equal(packagedImage)) + Expect(services.GetOnlineContainer(*deployment).Image).To(Equal(serviceImage)) + }) + + It("keeps an explicit init image ahead of the packaged image", func() { + const initImage = "registry.example.com/feast-init:custom" + featureStore := &feastdevv1.FeatureStore{} + Expect(k8sClient.Get(ctx, key, featureStore)).To(Succeed()) + featureStore.Spec.Services = &feastdevv1.FeatureStoreServices{ + InitImage: ptr(initImage), + } + Expect(k8sClient.Update(ctx, featureStore)).To(Succeed()) + + _, deployment := reconcileFeatureStore() + Expect(deployment.Spec.Template.Spec.InitContainers).To(HaveLen(2)) + Expect(deployment.Spec.Template.Spec.InitContainers[0].Image).To(Equal(initImage)) + Expect(deployment.Spec.Template.Spec.InitContainers[1].Image).To(Equal(initImage)) + Expect(services.GetOnlineContainer(*deployment).Image).To(Equal(packagedImage)) + }) + + It("supports path-only direct mode with an explicit service image", func() { + const serviceImage = "registry.example.com/online-server:air-gapped" + featureStore := &feastdevv1.FeatureStore{} + Expect(k8sClient.Get(ctx, key, featureStore)).To(Succeed()) + featureStore.Spec.FeastProjectDir.Packaged.Image = "" + featureStore.Spec.Services = &feastdevv1.FeatureStoreServices{ + DisableInitContainers: true, + OnlineStore: &feastdevv1.OnlineStore{ + Server: &feastdevv1.ServerConfigs{ + ContainerConfigs: feastdevv1.ContainerConfigs{ + DefaultCtrConfigs: feastdevv1.DefaultCtrConfigs{Image: ptr(serviceImage)}, + }, + }, + }, + } + Expect(k8sClient.Update(ctx, featureStore)).To(Succeed()) + + _, deployment := reconcileFeatureStore() + Expect(deployment.Spec.Template.Spec.InitContainers).To(BeEmpty()) + online := services.GetOnlineContainer(*deployment) + Expect(online.Image).To(Equal(serviceImage)) + Expect(online.WorkingDir).To(Equal(packagedRepoDir)) + }) + + It("retains the operator image fallback when the packaged image is omitted", func() { + featureStore := &feastdevv1.FeatureStore{} + Expect(k8sClient.Get(ctx, key, featureStore)).To(Succeed()) + featureStore.Spec.FeastProjectDir.Packaged.Image = "" + Expect(k8sClient.Update(ctx, featureStore)).To(Succeed()) + + _, deployment := reconcileFeatureStore() + initImage := deployment.Spec.Template.Spec.InitContainers[0].Image + Expect(initImage).NotTo(BeEmpty()) + Expect(services.GetOnlineContainer(*deployment).Image).To(Equal(initImage)) + }) + + DescribeTable("rejects packaged and staged repository path overlap", + func(featureRepoPath string) { + featureStore := &feastdevv1.FeatureStore{} + Expect(k8sClient.Get(ctx, key, featureStore)).To(Succeed()) + featureStore.Spec.FeastProjectDir.Packaged.FeatureRepoPath = featureRepoPath + Expect(k8sClient.Update(ctx, featureStore)).To(Succeed()) + + reconciler := &FeatureStoreReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: key}) + Expect(err).To(MatchError(ContainSubstring("overlaps staged repository path"))) + }, + Entry("equal paths", services.EphemeralPath+"/"+feastProject+"/"+services.FeatureRepoDir), + Entry("packaged path is an ancestor", services.EphemeralPath+"/"+feastProject), + Entry("packaged path is a descendant", services.EphemeralPath+"/"+feastProject+"/"+services.FeatureRepoDir+"/baked"), + ) + + It("allows similar path prefixes that do not overlap", func() { + featureStore := &feastdevv1.FeatureStore{} + Expect(k8sClient.Get(ctx, key, featureStore)).To(Succeed()) + featureStore.Spec.FeastProjectDir.Packaged.FeatureRepoPath = + services.EphemeralPath + "/" + feastProject + "/" + services.FeatureRepoDir + "-image" + Expect(k8sClient.Update(ctx, featureStore)).To(Succeed()) + + reconcileFeatureStore() + }) +}) diff --git a/infra/feast-operator/internal/controller/services/services.go b/infra/feast-operator/internal/controller/services/services.go index 764c0f2d9e3..6964938c093 100644 --- a/infra/feast-operator/internal/controller/services/services.go +++ b/infra/feast-operator/internal/controller/services/services.go @@ -18,6 +18,7 @@ package services import ( "errors" + "path" "strconv" "strings" @@ -454,6 +455,10 @@ func (feast *FeastServices) setPod(podSpec *corev1.PodSpec) error { } func (feast *FeastServices) setContainers(podSpec *corev1.PodSpec) error { + if err := feast.validatePackagedFeatureRepoPath(); err != nil { + return err + } + fsYamlB64, err := feast.GetServiceFeatureStoreYamlBase64() if err != nil { return err @@ -704,7 +709,7 @@ func (feast *FeastServices) setInitContainer(podSpec *corev1.PodSpec, fsYamlB64 feastProjectDir := applied.FeastProjectDir workingDir := getOfflineMountPath(feast.Handler.FeatureStore) projectPath := workingDir + "/" + applied.FeastProject - initImage := getInitContainerImage(applied.Services) + initImage := getInitContainerImage(&applied) container := corev1.Container{ Name: feastInitContainerName, Image: initImage, @@ -718,6 +723,7 @@ func (feast *FeastServices) setInitContainer(podSpec *corev1.PodSpec, fsYamlB64 WorkingDir: workingDir, } + featureRepoDir := feast.getFeatureRepoDir() var createCommand string if feastProjectDir.Init != nil { initSlice := []string{"feast", "init"} @@ -747,13 +753,36 @@ func (feast *FeastServices) setInitContainer(podSpec *corev1.PodSpec, fsYamlB64 if feastProjectDir.Git.EnvFrom != nil { container.EnvFrom = *feastProjectDir.Git.EnvFrom } + } else if feastProjectDir.Packaged != nil { + container.Env = append(container.Env, + corev1.EnvVar{ + Name: packagedFeatureRepoEnvVar, + Value: path.Clean(feastProjectDir.Packaged.FeatureRepoPath), + }, + corev1.EnvVar{ + Name: stagedFeatureRepoEnvVar, + Value: featureRepoDir, + }, + ) + container.Args = []string{ + "set -euo pipefail\n" + + "echo \"Staging packaged feast repository...\"\n" + + "if [[ ! -d \"${" + packagedFeatureRepoEnvVar + "}\" ]]; then " + + "echo \"Packaged feature repository not found: ${" + packagedFeatureRepoEnvVar + "}\" >&2; exit 1; fi\n" + + "rm -rf -- \"${" + stagedFeatureRepoEnvVar + "}\"\n" + + "mkdir -p -- \"${" + stagedFeatureRepoEnvVar + "}\"\n" + + "cp -a -- \"${" + packagedFeatureRepoEnvVar + "}/.\" \"${" + stagedFeatureRepoEnvVar + "}/\"\n" + + "printf '%s' \"${" + TmpFeatureStoreYamlEnvVar + "}\" | base64 -d > \"${" + stagedFeatureRepoEnvVar + "}/feature_store.yaml\"\n" + + "echo \"Packaged feast repository staging complete\"\n", + } } - featureRepoDir := feast.getFeatureRepoDir() - container.Args = []string{ - "echo \"Creating feast repository...\"\necho '" + createCommand + "'\n" + - "if [[ ! -d " + featureRepoDir + " ]]; then " + createCommand + "; fi;\n" + - "echo $" + TmpFeatureStoreYamlEnvVar + " | base64 -d \u003e " + featureRepoDir + "/feature_store.yaml;\necho \"Feast repo creation complete\";\n", + if feastProjectDir.Packaged == nil { + container.Args = []string{ + "echo \"Creating feast repository...\"\necho '" + createCommand + "'\n" + + "if [[ ! -d " + featureRepoDir + " ]]; then " + createCommand + "; fi;\n" + + "echo $" + TmpFeatureStoreYamlEnvVar + " | base64 -d \u003e " + featureRepoDir + "/feature_store.yaml;\necho \"Feast repo creation complete\";\n", + } } podSpec.InitContainers = append(podSpec.InitContainers, container) @@ -1421,6 +1450,9 @@ func (feast *FeastServices) mountEmptyDirVolumes(podSpec *corev1.PodSpec) { func (feast *FeastServices) getFeatureRepoDir() string { applied := feast.Handler.FeatureStore.Status.Applied + if applied.FeastProjectDir != nil && applied.FeastProjectDir.Packaged != nil && applied.Services.DisableInitContainers { + return path.Clean(applied.FeastProjectDir.Packaged.FeatureRepoPath) + } feastProjectDir := getOfflineMountPath(feast.Handler.FeatureStore) + "/" + applied.FeastProject if applied.FeastProjectDir != nil && applied.FeastProjectDir.Git != nil && len(applied.FeastProjectDir.Git.FeatureRepoPath) > 0 { return feastProjectDir + "/" + applied.FeastProjectDir.Git.FeatureRepoPath @@ -1428,6 +1460,39 @@ func (feast *FeastServices) getFeatureRepoDir() string { return feastProjectDir + "/" + FeatureRepoDir } +func (feast *FeastServices) validatePackagedFeatureRepoPath() error { + applied := feast.Handler.FeatureStore.Status.Applied + if applied.FeastProjectDir == nil || applied.FeastProjectDir.Packaged == nil { + return nil + } + + featureRepoPath := applied.FeastProjectDir.Packaged.FeatureRepoPath + cleanFeatureRepoPath := path.Clean(featureRepoPath) + if !path.IsAbs(featureRepoPath) || cleanFeatureRepoPath == "/" || cleanFeatureRepoPath != featureRepoPath { + return errors.New("packaged feature repository path " + strconv.Quote(featureRepoPath) + " must be a canonical absolute, non-root path") + } + + if !applied.Services.DisableInitContainers { + stagedFeatureRepoPath := path.Clean(feast.getFeatureRepoDir()) + if pathsOverlap(cleanFeatureRepoPath, stagedFeatureRepoPath) { + return errors.New( + "packaged feature repository path " + strconv.Quote(cleanFeatureRepoPath) + + " overlaps staged repository path " + strconv.Quote(stagedFeatureRepoPath), + ) + } + } + + return nil +} + +func pathsOverlap(firstPath, secondPath string) bool { + firstPath = path.Clean(firstPath) + secondPath = path.Clean(secondPath) + return firstPath == secondPath || + strings.HasPrefix(firstPath, secondPath+"/") || + strings.HasPrefix(secondPath, firstPath+"/") +} + func mountEmptyDirVolume(podSpec *corev1.PodSpec) { if podSpec != nil { volName := strings.TrimPrefix(EphemeralPath, "/") diff --git a/infra/feast-operator/internal/controller/services/services_types.go b/infra/feast-operator/internal/controller/services/services_types.go index f9b3303e3de..090696eccaf 100644 --- a/infra/feast-operator/internal/controller/services/services_types.go +++ b/infra/feast-operator/internal/controller/services/services_types.go @@ -26,6 +26,8 @@ import ( const ( TmpFeatureStoreYamlEnvVar = "TMP_FEATURE_STORE_YAML_BASE64" + packagedFeatureRepoEnvVar = "FEAST_PACKAGED_FEATURE_REPO_PATH" + stagedFeatureRepoEnvVar = "FEAST_STAGED_FEATURE_REPO_PATH" feastServerImageVar = "RELATED_IMAGE_FEATURE_SERVER" cronJobImageVar = "RELATED_IMAGE_CRON_JOB" FeatureStoreYamlCmKey = "feature_store.yaml" diff --git a/infra/feast-operator/internal/controller/services/util.go b/infra/feast-operator/internal/controller/services/util.go index e668ac2147d..84951f2077b 100644 --- a/infra/feast-operator/internal/controller/services/util.go +++ b/infra/feast-operator/internal/controller/services/util.go @@ -99,6 +99,7 @@ func ApplyDefaultsToStatus(cr *feastdevv1.FeatureStore) { if applied.Services == nil { applied.Services = &feastdevv1.FeatureStoreServices{} } + defaultFeatureServerImage := getFeatureServerImageForSpec(applied) services := applied.Services if services.RunFeastApplyOnInit == nil { services.RunFeastApplyOnInit = boolPtr(true) @@ -128,7 +129,7 @@ func ApplyDefaultsToStatus(cr *feastdevv1.FeatureStore) { } if services.Registry.Local.Server != nil { - setDefaultCtrConfigs(&services.Registry.Local.Server.ContainerConfigs.DefaultCtrConfigs) + setDefaultCtrConfigs(&services.Registry.Local.Server.ContainerConfigs.DefaultCtrConfigs, defaultFeatureServerImage) // Set default for GRPC: true if nil if services.Registry.Local.Server.GRPC == nil { defaultGRPC := true @@ -159,7 +160,7 @@ func ApplyDefaultsToStatus(cr *feastdevv1.FeatureStore) { } if services.OfflineStore.Server != nil { - setDefaultCtrConfigs(&services.OfflineStore.Server.ContainerConfigs.DefaultCtrConfigs) + setDefaultCtrConfigs(&services.OfflineStore.Server.ContainerConfigs.DefaultCtrConfigs, defaultFeatureServerImage) } } @@ -187,11 +188,11 @@ func ApplyDefaultsToStatus(cr *feastdevv1.FeatureStore) { if services.OnlineStore.Server == nil { services.OnlineStore.Server = &feastdevv1.ServerConfigs{} } - setDefaultCtrConfigs(&services.OnlineStore.Server.ContainerConfigs.DefaultCtrConfigs) + setDefaultCtrConfigs(&services.OnlineStore.Server.ContainerConfigs.DefaultCtrConfigs, defaultFeatureServerImage) } if services.UI != nil { - setDefaultCtrConfigs(&services.UI.ContainerConfigs.DefaultCtrConfigs) + setDefaultCtrConfigs(&services.UI.ContainerConfigs.DefaultCtrConfigs, defaultFeatureServerImage) } if applied.CronJob == nil { @@ -200,13 +201,20 @@ func ApplyDefaultsToStatus(cr *feastdevv1.FeatureStore) { setDefaultCronJobConfigs(applied.CronJob) } -func setDefaultCtrConfigs(defaultConfigs *feastdevv1.DefaultCtrConfigs) { +func setDefaultCtrConfigs(defaultConfigs *feastdevv1.DefaultCtrConfigs, defaultImage string) { if defaultConfigs.Image == nil { - img := getFeatureServerImage() + img := defaultImage defaultConfigs.Image = &img } } +func getFeatureServerImageForSpec(spec *feastdevv1.FeatureStoreSpec) string { + if spec != nil && spec.FeastProjectDir != nil && spec.FeastProjectDir.Packaged != nil && spec.FeastProjectDir.Packaged.Image != "" { + return spec.FeastProjectDir.Packaged.Image + } + return getFeatureServerImage() +} + func getFeatureServerImage() string { if img, exists := os.LookupEnv(feastServerImageVar); exists { return img @@ -215,12 +223,13 @@ func getFeatureServerImage() string { } // 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 +// Order: spec.services.initImage → spec.feastProjectDir.packaged.image → +// RELATED_IMAGE_FEATURE_SERVER → DefaultImage. +func getInitContainerImage(spec *feastdevv1.FeatureStoreSpec) string { + if spec != nil && spec.Services != nil && spec.Services.InitImage != nil && len(*spec.Services.InitImage) > 0 { + return *spec.Services.InitImage } - return getFeatureServerImage() + return getFeatureServerImageForSpec(spec) } func checkOfflineStoreFilePersistenceType(value string) error { diff --git a/infra/feast-operator/internal/controller/services/util_test.go b/infra/feast-operator/internal/controller/services/util_test.go index 55cd7118455..5a868d2d101 100644 --- a/infra/feast-operator/internal/controller/services/util_test.go +++ b/infra/feast-operator/internal/controller/services/util_test.go @@ -87,26 +87,32 @@ var _ = Describe("ApplyDefaultsToStatus", func() { func TestGetInitContainerImage(t *testing.T) { customInit := "quay.io/org/feast-init:custom" + packagedImage := "quay.io/org/feast-packaged:test" envImage := "quay.io/org/feast-env:test" - t.Run("uses initImage and ignores differing server images", func(t *testing.T) { + t.Run("uses initImage ahead of packaged and 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"), + got := getInitContainerImage(&feastdevv1.FeatureStoreSpec{ + FeastProjectDir: &feastdevv1.FeastProjectDir{ + Packaged: &feastdevv1.FeastPackagedOptions{Image: packagedImage}, + }, + Services: &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"), + OnlineStore: &feastdevv1.OnlineStore{ + Server: &feastdevv1.ServerConfigs{ + ContainerConfigs: feastdevv1.ContainerConfigs{ + DefaultCtrConfigs: feastdevv1.DefaultCtrConfigs{ + Image: ptr.To("quay.io/org/online:v1"), + }, }, }, }, @@ -117,9 +123,24 @@ func TestGetInitContainerImage(t *testing.T) { } }) + t.Run("uses packaged image ahead of RELATED_IMAGE_FEATURE_SERVER", func(t *testing.T) { + t.Setenv(feastServerImageVar, envImage) + got := getInitContainerImage(&feastdevv1.FeatureStoreSpec{ + FeastProjectDir: &feastdevv1.FeastProjectDir{ + Packaged: &feastdevv1.FeastPackagedOptions{Image: packagedImage}, + }, + Services: &feastdevv1.FeatureStoreServices{}, + }) + if got != packagedImage { + t.Fatalf("got %q, want %q", got, packagedImage) + } + }) + t.Run("falls back to RELATED_IMAGE_FEATURE_SERVER", func(t *testing.T) { t.Setenv(feastServerImageVar, envImage) - got := getInitContainerImage(&feastdevv1.FeatureStoreServices{}) + got := getInitContainerImage(&feastdevv1.FeatureStoreSpec{ + Services: &feastdevv1.FeatureStoreServices{}, + }) if got != envImage { t.Fatalf("got %q, want %q", got, envImage) } @@ -135,11 +156,16 @@ func TestGetInitContainerImage(t *testing.T) { t.Run("ignores empty initImage", func(t *testing.T) { t.Setenv(feastServerImageVar, envImage) - got := getInitContainerImage(&feastdevv1.FeatureStoreServices{ - InitImage: ptr.To(""), + got := getInitContainerImage(&feastdevv1.FeatureStoreSpec{ + FeastProjectDir: &feastdevv1.FeastProjectDir{ + Packaged: &feastdevv1.FeastPackagedOptions{Image: packagedImage}, + }, + Services: &feastdevv1.FeatureStoreServices{ + InitImage: ptr.To(""), + }, }) - if got != envImage { - t.Fatalf("got %q, want %q", got, envImage) + if got != packagedImage { + t.Fatalf("got %q, want %q", got, packagedImage) } }) } diff --git a/infra/feast-operator/test/api/featurestore_packaged_types_test.go b/infra/feast-operator/test/api/featurestore_packaged_types_test.go new file mode 100644 index 00000000000..97525ec1abe --- /dev/null +++ b/infra/feast-operator/test/api/featurestore_packaged_types_test.go @@ -0,0 +1,162 @@ +/* +Copyright 2026 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 api + +import ( + "context" + "strings" + + feastdevv1 "github.com/feast-dev/feast/infra/feast-operator/api/v1" + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type packagedFeatureStoreFactory func(name, featureRepoPath string) client.Object +type conflictingPackagedFeatureStoreFactory func(name, conflictingMode string) client.Object + +func newV1PackagedFeatureStore(name, featureRepoPath string) client.Object { + return &feastdevv1.FeatureStore{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespaceName}, + Spec: feastdevv1.FeatureStoreSpec{ + FeastProject: "test_project", + FeastProjectDir: &feastdevv1.FeastProjectDir{ + Packaged: &feastdevv1.FeastPackagedOptions{FeatureRepoPath: featureRepoPath}, + }, + }, + } +} + +func newV1Alpha1PackagedFeatureStore(name, featureRepoPath string) client.Object { + return &feastdevv1alpha1.FeatureStore{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespaceName}, + Spec: feastdevv1alpha1.FeatureStoreSpec{ + FeastProject: "test_project", + FeastProjectDir: &feastdevv1alpha1.FeastProjectDir{ + Packaged: &feastdevv1alpha1.FeastPackagedOptions{FeatureRepoPath: featureRepoPath}, + }, + }, + } +} + +func newV1ConflictingPackagedFeatureStore(name, conflictingMode string) client.Object { + featureStore := newV1PackagedFeatureStore(name, "/opt/feast/feature_repo").(*feastdevv1.FeatureStore) + switch conflictingMode { + case "init": + featureStore.Spec.FeastProjectDir.Init = &feastdevv1.FeastInitOptions{} + case "git": + featureStore.Spec.FeastProjectDir.Git = &feastdevv1.GitCloneOptions{ + URL: "https://example.com/feature-repo.git", + } + } + return featureStore +} + +func newV1Alpha1ConflictingPackagedFeatureStore(name, conflictingMode string) client.Object { + featureStore := newV1Alpha1PackagedFeatureStore(name, "/opt/feast/feature_repo").(*feastdevv1alpha1.FeatureStore) + switch conflictingMode { + case "init": + featureStore.Spec.FeastProjectDir.Init = &feastdevv1alpha1.FeastInitOptions{} + case "git": + featureStore.Spec.FeastProjectDir.Git = &feastdevv1alpha1.GitCloneOptions{ + URL: "https://example.com/feature-repo.git", + } + } + return featureStore +} + +var _ = Describe("Packaged feature repository path validation", func() { + ctx := context.Background() + apiVersions := []struct { + name string + id string + factory packagedFeatureStoreFactory + conflictingFactory conflictingPackagedFeatureStoreFactory + }{ + { + name: "feast.dev/v1", + id: "v1", + factory: newV1PackagedFeatureStore, + conflictingFactory: newV1ConflictingPackagedFeatureStore, + }, + { + name: "feast.dev/v1alpha1", + id: "v1alpha1", + factory: newV1Alpha1PackagedFeatureStore, + conflictingFactory: newV1Alpha1ConflictingPackagedFeatureStore, + }, + } + + for _, apiVersion := range apiVersions { + apiVersion := apiVersion + Context(apiVersion.name, func() { + DescribeTable("accepts canonical absolute non-root paths", + func(nameSuffix, featureRepoPath string) { + featureStore := apiVersion.factory( + "packaged-"+apiVersion.id+"-"+nameSuffix, + featureRepoPath, + ) + Expect(k8sClient.Create(ctx, featureStore)).To(Succeed()) + Expect(k8sClient.Delete(ctx, featureStore)).To(Succeed()) + }, + Entry("standard", "standard", "/opt/feast/feature_repo"), + Entry("hidden component", "hidden", "/opt/.feast/feature_repo"), + Entry("dot in component", "dot-name", "/opt/feature_repo.v2"), + ) + + DescribeTable("rejects non-canonical, relative, or root paths", + func(nameSuffix, featureRepoPath string) { + featureStore := apiVersion.factory( + "packaged-"+apiVersion.id+"-"+nameSuffix, + featureRepoPath, + ) + err := k8sClient.Create(ctx, featureStore) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue(), "expected invalid error, got %v", err) + Expect(strings.ToLower(err.Error())).To(ContainSubstring("canonical absolute, non-root path")) + }, + Entry("relative", "relative", "opt/feast/feature_repo"), + Entry("root", "root", "/"), + Entry("parent collapses to root", "parent-root", "/opt/.."), + Entry("leading parent traversal", "leading-parent", "/../x"), + Entry("repeated separator", "repeated-separator", "/opt//feature_repo"), + Entry("current-directory component", "current-dir", "/opt/./feature_repo"), + Entry("trailing separator", "trailing-separator", "/opt/feature_repo/"), + Entry("nested traversal", "nested-traversal", "/a/../../etc"), + Entry("repeated root separator", "repeated-root", "//"), + ) + + DescribeTable("rejects packaged together with another project directory mode", + func(nameSuffix, conflictingMode string) { + featureStore := apiVersion.conflictingFactory( + "packaged-"+apiVersion.id+"-"+nameSuffix, + conflictingMode, + ) + err := k8sClient.Create(ctx, featureStore) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue(), "expected invalid error, got %v", err) + Expect(err.Error()).To(ContainSubstring("One selection required between init, git, or packaged")) + }, + Entry("init", "with-init", "init"), + Entry("git", "with-git", "git"), + ) + }) + } +}) diff --git a/infra/feast-operator/test/api/suite_test.go b/infra/feast-operator/test/api/suite_test.go index 558068a7957..eef4718cf58 100644 --- a/infra/feast-operator/test/api/suite_test.go +++ b/infra/feast-operator/test/api/suite_test.go @@ -26,6 +26,7 @@ import ( . "github.com/onsi/gomega" feastdevv1 "github.com/feast-dev/feast/infra/feast-operator/api/v1" + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" @@ -73,6 +74,8 @@ var _ = BeforeSuite(func() { err = feastdevv1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = feastdevv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) // +kubebuilder:scaffold:scheme