diff --git a/.secrets.baseline b/.secrets.baseline index 4d389cae97c..0eb483b214d 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": 1113 + "line_number": 1118 } ], "infra/feast-operator/api/v1/zz_generated.deepcopy.go": [ @@ -980,7 +980,7 @@ "filename": "infra/feast-operator/api/v1/zz_generated.deepcopy.go", "hashed_secret": "c2028031c154bbe86fd69bef740855c74b927dcf", "is_verified": false, - "line_number": 1773 + "line_number": 1778 } ], "infra/feast-operator/api/v1alpha1/featurestore_types.go": [ diff --git a/docs/reference/feature-servers/mcp-server.md b/docs/reference/feature-servers/mcp-server.md index 0de8538e1c2..04bbedd896d 100644 --- a/docs/reference/feature-servers/mcp-server.md +++ b/docs/reference/feature-servers/mcp-server.md @@ -218,7 +218,7 @@ See [sdk/python/feast/mcp/docker/README.md](https://github.com/feast-dev/feast/b ## Deploying with the Feast Operator -Setting `spec.services.mcpServer` adds a dedicated `feast mcp` container to the FeatureStore deployment, exposed on its own Service on port 8100. The operator sets `--host` and `--port` so that they match the generated Service. Every other setting comes from a `feast_mcp.yaml` supplied in a ConfigMap, which the operator mounts read-only at `/etc/feast/mcp`. +Setting `spec.services.mcpServer` adds a dedicated `feast mcp` container to the FeatureStore deployment, exposed on its own Service on port 8100. The operator sets `--host` and `--port` so that they match the generated Service. `--host` is `0.0.0.0` unless `spec.services.mcpServer.dualStack` is `true`, which sets it to `::` and needs an image with `feast mcp --host ::` support (#6977). Every other setting comes from a `feast_mcp.yaml` supplied in a ConfigMap, which the operator mounts read-only at `/etc/feast/mcp`. The ConfigMap must set `server.transport` to `http`, `streamable-http` or `sse`, because the default `stdio` cannot serve the Service. Without a ConfigMap, the operator passes `--transport http`, unless `FEAST_MCP_TRANSPORT` is set in the container `env`. diff --git a/infra/feast-operator/api/v1/featurestore_types.go b/infra/feast-operator/api/v1/featurestore_types.go index 7e1e60a9d21..05994dc6302 100644 --- a/infra/feast-operator/api/v1/featurestore_types.go +++ b/infra/feast-operator/api/v1/featurestore_types.go @@ -935,6 +935,11 @@ type ServerConfigs struct { LogLevel *string `json:"logLevel,omitempty"` // Metrics exposes Prometheus-compatible metrics for the Feast server when enabled. Metrics *bool `json:"metrics,omitempty"` + // DualStack binds the server to the IPv6 wildcard address instead of 0.0.0.0, + // which serves both IPv6 and IPv4 clients on Linux. Enable it on IPv6-only or dual-stack clusters. + // Unset or false binds 0.0.0.0 (IPv4 only) for every server that takes a host flag, including the registry's REST server. + // +optional + DualStack *bool `json:"dualStack,omitempty"` // VolumeMounts defines the list of volumes that should be mounted into the feast container. // This allows attaching persistent storage, config files, secrets, or other resources // required by the Feast components. Ensure that each volume mount has a corresponding diff --git a/infra/feast-operator/api/v1/zz_generated.deepcopy.go b/infra/feast-operator/api/v1/zz_generated.deepcopy.go index a8f0ebded00..15b419b70fa 100644 --- a/infra/feast-operator/api/v1/zz_generated.deepcopy.go +++ b/infra/feast-operator/api/v1/zz_generated.deepcopy.go @@ -1671,6 +1671,11 @@ func (in *ServerConfigs) DeepCopyInto(out *ServerConfigs) { *out = new(bool) **out = **in } + if in.DualStack != nil { + in, out := &in.DualStack, &out.DualStack + *out = new(bool) + **out = **in + } if in.VolumeMounts != nil { in, out := &in.VolumeMounts, &out.VolumeMounts *out = make([]corev1.VolumeMount, len(*in)) diff --git a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml index ef939cf4fe7..a78223dd110 100644 --- a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml +++ b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml @@ -176,7 +176,7 @@ metadata: } ] capabilities: Basic Install - createdAt: "2026-08-21T03:30:58Z" + createdAt: "2026-09-25T01:00:43Z" operators.operatorframework.io/builder: operator-sdk-v1.41.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 name: feast-operator.v0.66.0 diff --git a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml index 9aeabbf17ca..3bfd768c7d1 100644 --- a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml +++ b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml @@ -1012,6 +1012,10 @@ spec: description: Server configuration (image, resources, env, TLS, etc.). properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -2290,6 +2294,10 @@ spec: - message: configMapRef.name is required rule: has(self.configMapRef.name) && size(self.configMapRef.name) > 0 + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable present @@ -2827,6 +2835,10 @@ spec: server: description: Creates a remote offline server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -3379,6 +3391,10 @@ spec: server: description: Creates a feature server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -4040,6 +4056,10 @@ spec: server: description: Creates a registry server container properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -5409,6 +5429,10 @@ spec: ui: description: Creates a UI server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable present @@ -8309,6 +8333,10 @@ spec: description: Server configuration (image, resources, env, TLS, etc.). properties: + dualStack: + description: DualStack binds the server to the + IPv6 wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment @@ -9601,6 +9629,10 @@ spec: - message: configMapRef.name is required rule: has(self.configMapRef.name) && size(self.configMapRef.name) > 0 + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -10144,6 +10176,10 @@ spec: server: description: Creates a remote offline server container properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -10704,6 +10740,10 @@ spec: server: description: Creates a feature server container properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -11379,6 +11419,10 @@ spec: server: description: Creates a registry server container properties: + dualStack: + description: DualStack binds the server to the + IPv6 wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment @@ -12766,6 +12810,10 @@ spec: ui: description: Creates a UI server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable 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 d1c1c2ea1bf..16db5abcb27 100644 --- a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml +++ b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml @@ -1012,6 +1012,10 @@ spec: description: Server configuration (image, resources, env, TLS, etc.). properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -2290,6 +2294,10 @@ spec: - message: configMapRef.name is required rule: has(self.configMapRef.name) && size(self.configMapRef.name) > 0 + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable present @@ -2827,6 +2835,10 @@ spec: server: description: Creates a remote offline server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -3379,6 +3391,10 @@ spec: server: description: Creates a feature server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -4040,6 +4056,10 @@ spec: server: description: Creates a registry server container properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -5409,6 +5429,10 @@ spec: ui: description: Creates a UI server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable present @@ -8309,6 +8333,10 @@ spec: description: Server configuration (image, resources, env, TLS, etc.). properties: + dualStack: + description: DualStack binds the server to the + IPv6 wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment @@ -9601,6 +9629,10 @@ spec: - message: configMapRef.name is required rule: has(self.configMapRef.name) && size(self.configMapRef.name) > 0 + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -10144,6 +10176,10 @@ spec: server: description: Creates a remote offline server container properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -10704,6 +10740,10 @@ spec: server: description: Creates a feature server container properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -11379,6 +11419,10 @@ spec: server: description: Creates a registry server container properties: + dualStack: + description: DualStack binds the server to the + IPv6 wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment @@ -12766,6 +12810,10 @@ spec: ui: description: Creates a UI server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable diff --git a/infra/feast-operator/dist/install.yaml b/infra/feast-operator/dist/install.yaml index c265b105da0..ab1a705cc23 100644 --- a/infra/feast-operator/dist/install.yaml +++ b/infra/feast-operator/dist/install.yaml @@ -1020,6 +1020,10 @@ spec: description: Server configuration (image, resources, env, TLS, etc.). properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -2298,6 +2302,10 @@ spec: - message: configMapRef.name is required rule: has(self.configMapRef.name) && size(self.configMapRef.name) > 0 + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable present @@ -2835,6 +2843,10 @@ spec: server: description: Creates a remote offline server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -3387,6 +3399,10 @@ spec: server: description: Creates a feature server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -4048,6 +4064,10 @@ spec: server: description: Creates a registry server container properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -5417,6 +5437,10 @@ spec: ui: description: Creates a UI server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable present @@ -8317,6 +8341,10 @@ spec: description: Server configuration (image, resources, env, TLS, etc.). properties: + dualStack: + description: DualStack binds the server to the + IPv6 wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment @@ -9609,6 +9637,10 @@ spec: - message: configMapRef.name is required rule: has(self.configMapRef.name) && size(self.configMapRef.name) > 0 + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -10152,6 +10184,10 @@ spec: server: description: Creates a remote offline server container properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -10712,6 +10748,10 @@ spec: server: description: Creates a feature server container properties: + dualStack: + description: DualStack binds the server to the IPv6 + wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable @@ -11387,6 +11427,10 @@ spec: server: description: Creates a registry server container properties: + dualStack: + description: DualStack binds the server to the + IPv6 wildcard address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment @@ -12774,6 +12818,10 @@ spec: ui: description: Creates a UI server container properties: + dualStack: + description: DualStack binds the server to the IPv6 wildcard + address instead of 0.0.0. + type: boolean env: items: description: EnvVar represents an environment variable diff --git a/infra/feast-operator/docs/api/markdown/ref.md b/infra/feast-operator/docs/api/markdown/ref.md index 953e1cd0c4e..44fd26412c5 100644 --- a/infra/feast-operator/docs/api/markdown/ref.md +++ b/infra/feast-operator/docs/api/markdown/ref.md @@ -606,6 +606,9 @@ _Appears in:_ | `logLevel` _string_ | LogLevel sets the logging level for the server Allowed values: "debug", "info", "warning", "error", "critical". | | `metrics` _boolean_ | Metrics exposes Prometheus-compatible metrics for the Feast server when enabled. | +| `dualStack` _boolean_ | DualStack binds the server to the IPv6 wildcard address instead of 0.0.0.0, +which serves both IPv6 and IPv4 clients on Linux. Enable it on IPv6-only or dual-stack clusters. +Unset or false binds 0.0.0.0 (IPv4 only) for every server that takes a host flag, including the registry's REST server. | | `volumeMounts` _[VolumeMount](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#volumemount-v1-core) array_ | VolumeMounts defines the list of volumes that should be mounted into the feast container. This allows attaching persistent storage, config files, secrets, or other resources required by the Feast components. Ensure that each volume mount has a corresponding @@ -1081,6 +1084,9 @@ _Appears in:_ | `logLevel` _string_ | LogLevel sets the logging level for the server Allowed values: "debug", "info", "warning", "error", "critical". | | `metrics` _boolean_ | Metrics exposes Prometheus-compatible metrics for the Feast server when enabled. | +| `dualStack` _boolean_ | DualStack binds the server to the IPv6 wildcard address instead of 0.0.0.0, +which serves both IPv6 and IPv4 clients on Linux. Enable it on IPv6-only or dual-stack clusters. +Unset or false binds 0.0.0.0 (IPv4 only) for every server that takes a host flag, including the registry's REST server. | | `volumeMounts` _[VolumeMount](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#volumemount-v1-core) array_ | VolumeMounts defines the list of volumes that should be mounted into the feast container. This allows attaching persistent storage, config files, secrets, or other resources required by the Feast components. Ensure that each volume mount has a corresponding @@ -1181,6 +1187,9 @@ _Appears in:_ | `logLevel` _string_ | LogLevel sets the logging level for the server Allowed values: "debug", "info", "warning", "error", "critical". | | `metrics` _boolean_ | Metrics exposes Prometheus-compatible metrics for the Feast server when enabled. | +| `dualStack` _boolean_ | DualStack binds the server to the IPv6 wildcard address instead of 0.0.0.0, +which serves both IPv6 and IPv4 clients on Linux. Enable it on IPv6-only or dual-stack clusters. +Unset or false binds 0.0.0.0 (IPv4 only) for every server that takes a host flag, including the registry's REST server. | | `volumeMounts` _[VolumeMount](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#volumemount-v1-core) array_ | VolumeMounts defines the list of volumes that should be mounted into the feast container. This allows attaching persistent storage, config files, secrets, or other resources required by the Feast components. Ensure that each volume mount has a corresponding diff --git a/infra/feast-operator/internal/controller/services/bind_host_test.go b/infra/feast-operator/internal/controller/services/bind_host_test.go new file mode 100644 index 00000000000..3b3301b3bdb --- /dev/null +++ b/infra/feast-operator/internal/controller/services/bind_host_test.go @@ -0,0 +1,68 @@ +/* +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 ( + "testing" + + "k8s.io/utils/ptr" + + feastdevv1 "github.com/feast-dev/feast/infra/feast-operator/api/v1" +) + +func TestWithBindHost(t *testing.T) { + dual := &feastdevv1.ServerConfigs{DualStack: ptr.To(true)} + cases := []struct { + name string + feastType FeastServiceType + args []string + cfg *feastdevv1.ServerConfigs + want []string + }{ + {"unset keeps ipv4", OnlineFeastType, []string{"serve", "-h", hostAllIPv4}, &feastdevv1.ServerConfigs{}, []string{"serve", "-h", hostAllIPv4}}, + {"nil config keeps ipv4", UIFeastType, []string{"ui", "-h", hostAllIPv4}, nil, []string{"ui", "-h", hostAllIPv4}}, + {"false keeps ipv4", OfflineFeastType, []string{"serve_offline", "-h", hostAllIPv4}, &feastdevv1.ServerConfigs{DualStack: ptr.To(false)}, []string{"serve_offline", "-h", hostAllIPv4}}, + {"online bracketed", OnlineFeastType, []string{"serve", "--metrics", "-h", hostAllIPv4}, dual, []string{"serve", "--metrics", "-h", "[::]"}}, + {"offline bracketed", OfflineFeastType, []string{"serve_offline", "-h", hostAllIPv4}, dual, []string{"serve_offline", "-h", "[::]"}}, + {"ui bare", UIFeastType, []string{"ui", "-h", hostAllIPv4}, dual, []string{"ui", "-h", "::"}}, + {"-h as first arg is still replaced", LineageFeastType, []string{"-h", hostAllIPv4}, dual, []string{"-h", "::"}}, + {"-h with no following value does not panic or get touched", OnlineFeastType, []string{"serve", "-h"}, dual, []string{"serve", "-h"}}, + {"lineage bare", LineageFeastType, []string{"serve_lineage", "-h", hostAllIPv4}, dual, []string{"serve_lineage", "-h", "::"}}, + {"mcp --host bare", McpServerFeastType, []string{"feast", "mcp", "--host", hostAllIPv4, "--port", "8100"}, dual, []string{"feast", "mcp", "--host", "::", "--port", "8100"}}, + {"mcp --host unset keeps ipv4", McpServerFeastType, []string{"feast", "mcp", "--host", hostAllIPv4, "--port", "8100"}, &feastdevv1.ServerConfigs{}, []string{"feast", "mcp", "--host", hostAllIPv4, "--port", "8100"}}, + {"registry has no host flag", RegistryFeastType, []string{"serve_registry"}, dual, []string{"serve_registry"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := append([]string{}, tc.args...) + got := withBindHost(tc.feastType, in, tc.cfg) + if len(got) != len(tc.want) { + t.Fatalf("got %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("got %v, want %v", got, tc.want) + } + } + for i := range in { + if in[i] != tc.args[i] { + t.Fatalf("input mutated: %v", in) + } + } + }) + } +} diff --git a/infra/feast-operator/internal/controller/services/registry_bind_host_test.go b/infra/feast-operator/internal/controller/services/registry_bind_host_test.go new file mode 100644 index 00000000000..276dafefe32 --- /dev/null +++ b/infra/feast-operator/internal/controller/services/registry_bind_host_test.go @@ -0,0 +1,71 @@ +/* +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 ( + "testing" + + feastdevv1 "github.com/feast-dev/feast/infra/feast-operator/api/v1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + "k8s.io/utils/ptr" +) + +func registryFeastServices(restEnabled bool, dualStack *bool) *FeastServices { + fs := &feastdevv1.FeatureStore{} + fs.Status.Applied.Services = &feastdevv1.FeatureStoreServices{ + Registry: &feastdevv1.Registry{ + Local: &feastdevv1.LocalRegistryConfig{ + Server: &feastdevv1.RegistryServerConfigs{ + ServerConfigs: feastdevv1.ServerConfigs{DualStack: dualStack}, + GRPC: ptr.To(true), + RestAPI: ptr.To(restEnabled), + }, + }, + }, + } + return &FeastServices{Handler: handler.FeastHandler{FeatureStore: fs}} +} + +func TestRegistryContainerCommandHostFlag(t *testing.T) { + cases := []struct { + name string + restEnabled bool + dualStack *bool + wantHost string // empty: no -h expected + }{ + {"unset keeps ipv4", true, nil, hostAllIPv4}, + {"explicit false keeps ipv4", true, ptr.To(false), hostAllIPv4}, + {"explicit true binds bare ipv6 wildcard", true, ptr.To(true), hostAllIPv6}, + {"rest disabled never renders -h", false, ptr.To(true), ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + feast := registryFeastServices(tc.restEnabled, tc.dualStack) + args := feast.getContainerCommand(RegistryFeastType) + + gotHost := "" + for i, a := range args { + if a == "-h" && i+1 < len(args) { + gotHost = args[i+1] + } + } + if gotHost != tc.wantHost { + t.Fatalf("got args %v, want -h %q", args, tc.wantHost) + } + }) + } +} diff --git a/infra/feast-operator/internal/controller/services/repo_config_test.go b/infra/feast-operator/internal/controller/services/repo_config_test.go index b159b0c01e1..700c9995e9b 100644 --- a/infra/feast-operator/internal/controller/services/repo_config_test.go +++ b/infra/feast-operator/internal/controller/services/repo_config_test.go @@ -23,9 +23,11 @@ import ( . "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" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -854,6 +856,45 @@ var _ = Describe("Repo Config", func() { Expect(*repoConfig.OpenLineage.Consumer.StandaloneServer).To(BeTrue()) }) + It("should bind the lineage deployment dual-stack when configured", func() { + featureStore := minimalFeatureStore() + remoteHost := "feast-banking-registry.feast.svc.cluster.local:443" + featureStore.Spec.Services = &feastdevv1.FeatureStoreServices{ + Registry: &feastdevv1.Registry{ + Remote: &feastdevv1.RemoteRegistryConfig{ + Hostname: &remoteHost, + }, + }, + } + featureStore.Spec.OpenLineage = &feastdevv1.OpenLineageConfig{ + Enabled: true, + Consumer: &feastdevv1.OpenLineageConsumerConfig{ + Enabled: true, + LineageServer: &feastdevv1.LineageServerConfig{ + Server: &feastdevv1.ServerConfigs{DualStack: ptr.To(true)}, + }, + }, + } + ApplyDefaultsToStatus(featureStore) + featureStore.Status.ServiceHostnames.Registry = remoteHost + + feast := FeastServices{ + Handler: handler.FeastHandler{ + Client: fake.NewClientBuilder().WithScheme(k8sscheme.Scheme).Build(), + Context: context.Background(), + FeatureStore: featureStore, + Scheme: k8sscheme.Scheme, + }, + } + deploy := &appsv1.Deployment{} + Expect(feast.setLineageDeployment(deploy)).To(Succeed()) + + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(deploy.Spec.Template.Spec.Containers[0].Command).To( + Equal([]string{feastCommand, "serve_lineage", "-h", hostAllIPv6})) + Expect(deploy.Spec.Template.Spec.Containers[0].Args).To(Equal([]string{"-p", "6580"})) + }) + It("should generate lineage repo config with feastRef remote registry", func() { featureStore := minimalFeatureStore() featureStore.Spec.Services = &feastdevv1.FeatureStoreServices{ diff --git a/infra/feast-operator/internal/controller/services/services.go b/infra/feast-operator/internal/controller/services/services.go index d2f324898da..22693e906a5 100644 --- a/infra/feast-operator/internal/controller/services/services.go +++ b/infra/feast-operator/internal/controller/services/services.go @@ -770,8 +770,10 @@ func (feast *FeastServices) getContainerCommand(feastType FeastServiceType) []st if feast.isRegistryRestEnabled() { deploySettings.Args = append(deploySettings.Args, "--rest-api") deploySettings.Args = append(deploySettings.Args, "--rest-port", strconv.Itoa(int(getTargetRestPort(feastType, tls)))) + deploySettings.Args = append(deploySettings.Args, "-h", hostAllIPv4) } } + deploySettings.Args = withBindHost(feastType, deploySettings.Args, feast.getServerConfigs(feastType)) // Add worker configuration options for online store (feast serve) if feastType == OnlineFeastType { @@ -813,6 +815,25 @@ func (feast *FeastServices) getContainerCommand(feastType FeastServiceType) []st return feastCommand } +// withBindHost returns a copy of args with the "-h"/"--host" host set to the IPv6 wildcard when dual-stack is enabled. +// gunicorn (online) and Arrow Flight (offline) need the bracketed form; uvicorn (ui, lineage, mcp) rejects it. +func withBindHost(feastType FeastServiceType, args []string, serverConfigs *feastdevv1.ServerConfigs) []string { + out := append([]string{}, args...) + if serverConfigs == nil || serverConfigs.DualStack == nil || !*serverConfigs.DualStack { + return out + } + host := hostAllIPv6 + if feastType == OnlineFeastType || feastType == OfflineFeastType { + host = hostAllIPv6Bracketed + } + for i := 0; i+1 < len(out); i++ { + if out[i] == "-h" || out[i] == "--host" { + out[i+1] = host + } + } + return out +} + // getMcpServerCommand builds the `feast mcp` command for the standalone MCP server container. // The operator owns the bind host/port (so they match the generated Service); everything else // (transport, upstream feature/registry URLs, auth, observability) is read from the mounted @@ -830,7 +851,7 @@ func (feast *FeastServices) getMcpServerCommand() []string { } else if !feast.hasMcpServerTransportEnv() { cmd = append(cmd, "--transport", "http") } - return cmd + return withBindHost(McpServerFeastType, cmd, feast.getServerConfigs(McpServerFeastType)) } // hasMcpServerTransportEnv reports whether the user set the MCP transport through mcpServer.env. @@ -1696,7 +1717,7 @@ func (feast *FeastServices) setLineageDeployment(deploy *appsv1.Deployment) erro container := corev1.Container{ Name: string(LineageFeastType), Image: image, - Command: append([]string{feastCommand}, svcConsts.Args...), + Command: append([]string{feastCommand}, withBindHost(LineageFeastType, svcConsts.Args, serverConfigs)...), Args: []string{"-p", fmt.Sprintf("%d", port)}, Ports: []corev1.ContainerPort{ { diff --git a/infra/feast-operator/internal/controller/services/services_test.go b/infra/feast-operator/internal/controller/services/services_test.go index 981d25f86af..ad1f43ccb7e 100644 --- a/infra/feast-operator/internal/controller/services/services_test.go +++ b/infra/feast-operator/internal/controller/services/services_test.go @@ -588,6 +588,25 @@ var _ = Describe("Registry Service", func() { })) }) + It("should bind the online service dual-stack when configured", func() { + featureStore.Spec.Services.OnlineStore = &feastdevv1.OnlineStore{ + Server: &feastdevv1.ServerConfigs{Metrics: ptr.To(true), DualStack: ptr.To(true)}, + } + + Expect(k8sClient.Update(ctx, featureStore)).To(Succeed()) + Expect(feast.ApplyDefaults()).To(Succeed()) + applySpecToStatus(featureStore) + feast.refreshFeatureStore(ctx, typeNamespacedName) + + Expect(feast.deployFeastServiceByType(OnlineFeastType)).To(Succeed()) + deployment := feast.initFeastDeploy() + Expect(feast.setDeployment(deployment)).To(Succeed()) + + onlineContainer := GetOnlineContainer(*deployment) + Expect(onlineContainer).NotTo(BeNil()) + Expect(onlineContainer.Command).To(Equal([]string{feastCommand, "serve", "--metrics", "-h", "[::]", "-p", "6566"})) + }) + It("should handle empty NodeSelector gracefully", func() { // Set empty NodeSelector emptyNodeSelector := map[string]string{} diff --git a/infra/feast-operator/internal/controller/services/services_types.go b/infra/feast-operator/internal/controller/services/services_types.go index a67878b0302..433204a4331 100644 --- a/infra/feast-operator/internal/controller/services/services_types.go +++ b/infra/feast-operator/internal/controller/services/services_types.go @@ -133,6 +133,8 @@ const ( stringTrue = "true" stringFalse = "false" hostAllIPv4 = "0.0.0.0" + hostAllIPv6 = "::" + hostAllIPv6Bracketed = "[::]" tlsCertKey = "tls.crt" DefaultNs = "default" feastCommand = "feast" @@ -208,12 +210,12 @@ var ( TargetRestHttpsPort: 6573, }, UIFeastType: { - Args: []string{"ui", "-h", "0.0.0.0"}, + Args: []string{"ui", "-h", hostAllIPv4}, TargetHttpPort: 8888, TargetHttpsPort: 8443, }, LineageFeastType: { - Args: []string{"serve_lineage", "-h", "0.0.0.0"}, + Args: []string{"serve_lineage", "-h", hostAllIPv4}, TargetHttpPort: 6580, TargetHttpsPort: 6581, },