diff --git a/.secrets.baseline b/.secrets.baseline index b815a28edc9..93f1c3f5910 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1564,5 +1564,5 @@ } ] }, - "generated_at": "2026-07-17T12:34:13Z" + "generated_at": "2026-07-23T08:52:32Z" } diff --git a/infra/feast-operator/internal/controller/services/namespace_registry.go b/infra/feast-operator/internal/controller/services/namespace_registry.go index dcea98a5764..122e7ba9e98 100644 --- a/infra/feast-operator/internal/controller/services/namespace_registry.go +++ b/infra/feast-operator/internal/controller/services/namespace_registry.go @@ -36,8 +36,22 @@ type NamespaceRegistryData struct { Namespaces map[string][]string `json:"namespaces"` } +// isProtectedProject checks if this CR is annotated as a protected project +func (feast *FeastServices) isProtectedProject() bool { + annotations := feast.Handler.FeatureStore.GetAnnotations() + return annotations[ProtectedProjectAnnotation] == "true" +} + // deployNamespaceRegistry creates and manages the namespace registry ConfigMap func (feast *FeastServices) deployNamespaceRegistry() error { + // Skip namespace registry for protected projects. + // Protected projects are managed externally and should not be visible to other instances. + if feast.isProtectedProject() { + logger := log.FromContext(feast.Handler.Context) + logger.V(1).Info("Skipping namespace registry for protected project", "project", feast.Handler.FeatureStore.Spec.FeastProject) + return nil + } + // Check if we can determine the target namespace before creating any resources targetNamespace, err := feast.getNamespaceRegistryNamespace() if err != nil { @@ -230,6 +244,11 @@ func (feast *FeastServices) getNamespaceRegistryNamespace() (string, error) { // AddToNamespaceRegistry adds a feature store instance to the namespace registry func (feast *FeastServices) AddToNamespaceRegistry() error { + // Skip for protected projects — they should not appear in the namespace registry. + if feast.isProtectedProject() { + return nil + } + logger := log.FromContext(feast.Handler.Context) targetNamespace, err := feast.getNamespaceRegistryNamespace() if err != nil { diff --git a/infra/feast-operator/internal/controller/services/services.go b/infra/feast-operator/internal/controller/services/services.go index 4d5429bbbde..4c577b2ca95 100644 --- a/infra/feast-operator/internal/controller/services/services.go +++ b/infra/feast-operator/internal/controller/services/services.go @@ -472,6 +472,20 @@ func (feast *FeastServices) setContainers(podSpec *corev1.PodSpec) error { if feast.isUiServer() { feast.setContainer(&podSpec.Containers, UIFeastType, fsYamlB64) } + + // When the CR is annotated as a protected project, set FEAST_PROTECTED_PROJECT=true + // so the registry server tags its own project in the shared registry. + // Other FeatureStore instances then exclude this project automatically. + if feast.isProtectedProject() { + protectedEnv := corev1.EnvVar{ + Name: "FEAST_PROTECTED_PROJECT", + Value: "true", + } + for i := range podSpec.Containers { + podSpec.Containers[i].Env = append(podSpec.Containers[i].Env, protectedEnv) + } + } + return nil } diff --git a/infra/feast-operator/internal/controller/services/services_types.go b/infra/feast-operator/internal/controller/services/services_types.go index 098362af96b..06e614de087 100644 --- a/infra/feast-operator/internal/controller/services/services_types.go +++ b/infra/feast-operator/internal/controller/services/services_types.go @@ -40,6 +40,14 @@ const ( NamespaceRegistryDataKey = "namespaces" DefaultKubernetesNamespace = "feast-operator-system" + // ProtectedProjectAnnotation is the annotation key on a FeatureStore CR + // that marks its project as protected. Protected projects are excluded + // from project listings and shielded from teardown by other instances. + // When this annotation is "true", the operator sets FEAST_PROTECTED_PROJECT=true + // on the server pods, which causes the server to tag the project in the + // shared registry on startup. + ProtectedProjectAnnotation = "feast.dev/protected-project" + HttpPort = 80 HttpsPort = 443 HttpScheme = "http" diff --git a/sdk/python/feast/api/registry/rest/rest_registry_server.py b/sdk/python/feast/api/registry/rest/rest_registry_server.py index b115d15aff7..8dd3415cd70 100644 --- a/sdk/python/feast/api/registry/rest/rest_registry_server.py +++ b/sdk/python/feast/api/registry/rest/rest_registry_server.py @@ -327,6 +327,10 @@ def start_server( ): import uvicorn + from feast.registry_server import _sync_protected_project_tag + + _sync_protected_project_tag(self.store) + if tls_key_path and tls_cert_path: logger.info("Starting REST registry server in TLS(SSL) mode") logger.info(f"REST registry server listening on https://localhost:{port}") diff --git a/sdk/python/feast/constants.py b/sdk/python/feast/constants.py index 240dda3311b..a687b91cf19 100644 --- a/sdk/python/feast/constants.py +++ b/sdk/python/feast/constants.py @@ -46,3 +46,12 @@ # Default feature server registry ttl (seconds) DEFAULT_FEATURE_SERVER_REGISTRY_TTL = 5 + +# Tag key set on Feast projects that are protected. +# Protected projects are excluded from project listings, +# shielded from teardown, and hidden from delete operations. +PROTECTED_PROJECT_TAG = "feast.dev/protected-project" + +# Environment variable set by the operator on protected project pods. +# When "true", the Feast server tags its own project as protected. +FEAST_PROTECTED_PROJECT_ENV = "FEAST_PROTECTED_PROJECT" diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index c9888768003..34a77310bac 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -1884,6 +1884,21 @@ def _emit_openlineage_apply(self, objects: List[Any]): def teardown(self): """Tears down all local and cloud resources for the feature store.""" + from feast.constants import PROTECTED_PROJECT_TAG + + # Prevent teardown of protected projects + try: + current = self.registry.get_project(name=self.project, allow_cache=False) + if current and current.tags.get(PROTECTED_PROJECT_TAG) == "true": + raise ValueError( + f'Teardown is not allowed on protected project "{self.project}". ' + "Protected projects are managed externally and cannot be torn down via Feast." + ) + except ValueError: + raise + except Exception: + pass + tables: List[BaseFeatureView] = [] tables.extend(self.list_feature_views()) tables.extend(self.list_label_views()) @@ -1891,7 +1906,10 @@ def teardown(self): entities = self.list_entities() self._get_provider().teardown_infra(self.project, tables, entities) # type: ignore[arg-type] - self.registry.teardown() + + for project in self.list_projects(): + self.registry.delete_project(project.name) + self._teardown_openlineage() def _teardown_openlineage(self): @@ -4732,6 +4750,9 @@ def list_projects( """ Retrieves the list of projects from the registry. + Protected projects (feast.dev/protected-project=true) are automatically + excluded from the results. + Args: allow_cache: Whether to allow returning projects from a cached registry. tags: Filter by tags. @@ -4739,7 +4760,10 @@ def list_projects( Returns: A list of projects. """ - return self.registry.list_projects(allow_cache=allow_cache, tags=tags) + from feast.constants import PROTECTED_PROJECT_TAG + + projects = self.registry.list_projects(allow_cache=allow_cache, tags=tags) + return [p for p in projects if p.tags.get(PROTECTED_PROJECT_TAG) != "true"] def get_project(self, name: Optional[str]) -> Project: """ @@ -4766,7 +4790,21 @@ def delete_project(self, name: str, commit: bool = True) -> None: Raises: ProjectNotFoundException: The project could not be found. + ValueError: If the project is protected. """ + from feast.constants import PROTECTED_PROJECT_TAG + + try: + project = self.registry.get_project(name=name, allow_cache=False) + if project and project.tags.get(PROTECTED_PROJECT_TAG) == "true": + raise ValueError( + f'Cannot delete protected project "{name}". ' + "Protected projects are managed externally." + ) + except ValueError: + raise + except Exception: + pass return self.registry.delete_project(name, commit=commit) def list_saved_datasets( diff --git a/sdk/python/feast/registry_server.py b/sdk/python/feast/registry_server.py index f04bdafa194..46c822a88cc 100644 --- a/sdk/python/feast/registry_server.py +++ b/sdk/python/feast/registry_server.py @@ -1487,17 +1487,26 @@ def GetProject(self, request: RegistryServer_pb2.GetProjectRequest, context): ).to_proto() def ListProjects(self, request: RegistryServer_pb2.ListProjectsRequest, context): - paginated_projects, pagination_metadata = apply_pagination_and_sorting( - permitted_resources( - resources=cast( - list[FeastObject], - self.proxied_registry.list_projects( - allow_cache=request.allow_cache, - tags=dict(request.tags), - ), + from feast.constants import PROTECTED_PROJECT_TAG + + permitted_projects = permitted_resources( + resources=cast( + list[FeastObject], + self.proxied_registry.list_projects( + allow_cache=request.allow_cache, + tags=dict(request.tags), ), - actions=AuthzedAction.DESCRIBE, ), + actions=AuthzedAction.DESCRIBE, + ) + + # Exclude protected projects after RBAC check + visible_projects = [ + p for p in permitted_projects if p.tags.get(PROTECTED_PROJECT_TAG) != "true" + ] + + paginated_projects, pagination_metadata = apply_pagination_and_sorting( + visible_projects, pagination=request.pagination, sorting=request.sorting, ) @@ -1724,6 +1733,57 @@ def GetFeature(self, request: RegistryServer_pb2.GetFeatureRequest, context): ) +def _sync_protected_project_tag(store: FeatureStore): + """Sync the protected project tag based on FEAST_PROTECTED_PROJECT env var. + + When FEAST_PROTECTED_PROJECT=true, tags the project as protected in the + shared registry. When the env var is absent or false, removes the tag + if it was previously set — allowing temporary protection that can be + reversed by removing the annotation from the FeatureStore CR. + """ + import os + + from feast.constants import FEAST_PROTECTED_PROJECT_ENV, PROTECTED_PROJECT_TAG + + should_protect = os.environ.get(FEAST_PROTECTED_PROJECT_ENV, "").lower() == "true" + + try: + existing = store.registry.get_project(name=store.project, allow_cache=False) + except Exception: + if should_protect: + from feast.project import Project + + project = Project( + name=store.project, + tags={PROTECTED_PROJECT_TAG: "true"}, + ) + store.registry.apply_project(project, commit=True) + logger.info( + "Tagged project '%s' as protected (%s=true)", + store.project, + PROTECTED_PROJECT_TAG, + ) + return + + is_protected = existing.tags.get(PROTECTED_PROJECT_TAG) == "true" + + if should_protect and not is_protected: + existing.tags[PROTECTED_PROJECT_TAG] = "true" + store.registry.apply_project(existing, commit=True) + logger.info( + "Tagged project '%s' as protected (%s=true)", + store.project, + PROTECTED_PROJECT_TAG, + ) + elif not should_protect and is_protected: + del existing.tags[PROTECTED_PROJECT_TAG] + store.registry.apply_project(existing, commit=True) + logger.info( + "Removed protected tag from project '%s'", + store.project, + ) + + def start_server( store: FeatureStore, port: int, @@ -1731,6 +1791,8 @@ def start_server( tls_key_path: str = "", tls_cert_path: str = "", ): + _sync_protected_project_tag(store) + auth_manager_type = str_to_auth_manager_type(store.config.auth_config.type) init_security_manager(auth_type=auth_manager_type, fs=store) init_auth_manager(