diff --git a/plugins/kubernetes-ingestor/src/extensions.ts b/plugins/kubernetes-ingestor/src/extensions.ts new file mode 100644 index 00000000..b4ac3a5c --- /dev/null +++ b/plugins/kubernetes-ingestor/src/extensions.ts @@ -0,0 +1,21 @@ +import { createExtensionPoint } from '@backstage/backend-plugin-api'; +import { KubernetesResourceFilter } from './types'; + +/** + * Extension point for narrowing what the ingestor puts in the catalog. + * + * The configuration options cover the two common cases — a namespace list and an + * annotation on the object itself — but neither reaches resources that are spread + * across every namespace and deployed from charts the adopter does not own. A filter + * is code, so it can decide on any part of the resource: name, labels, owner + * references, whatever the installation needs. + */ +export interface KubernetesIngestorExtensionPoint { + /** Registers one or more filters. All of them must pass for a resource to be ingested. */ + addResourceFilter(...filters: KubernetesResourceFilter[]): void; +} + +export const kubernetesIngestorExtensionPoint = + createExtensionPoint({ + id: 'kubernetes-ingestor', + }); diff --git a/plugins/kubernetes-ingestor/src/index.ts b/plugins/kubernetes-ingestor/src/index.ts index e5f74df4..2518b126 100644 --- a/plugins/kubernetes-ingestor/src/index.ts +++ b/plugins/kubernetes-ingestor/src/index.ts @@ -2,3 +2,10 @@ export { catalogModuleKubernetesIngestor as default } from './module'; export { KubernetesEntityProvider, XRDTemplateEntityProvider } from './providers'; export type { DeltaEvent } from './providers'; export type { KubernetesResourceFetcher, KubernetesResourceFetcherOptions } from './types'; +export { kubernetesIngestorExtensionPoint } from './extensions'; +export type { KubernetesIngestorExtensionPoint } from './extensions'; +export type { + KubernetesResourceFilter, + KubernetesResourceFilterContext, + KubernetesResourceFilterInput, +} from './types'; diff --git a/plugins/kubernetes-ingestor/src/module.ts b/plugins/kubernetes-ingestor/src/module.ts index bf6dff86..fc677144 100644 --- a/plugins/kubernetes-ingestor/src/module.ts +++ b/plugins/kubernetes-ingestor/src/module.ts @@ -8,6 +8,8 @@ import { import { EventParams, eventsServiceRef } from '@backstage/plugin-events-node'; import { KubernetesEntityProvider, RGDTemplateEntityProvider, XRDTemplateEntityProvider } from './providers'; import { DefaultKubernetesResourceFetcher } from './services'; +import { kubernetesIngestorExtensionPoint } from './extensions'; +import { KubernetesResourceFilter } from './types'; interface DeltaEventPayload { action: 'upsert' | 'delete'; @@ -57,6 +59,13 @@ export const catalogModuleKubernetesIngestor = createBackendModule({ pluginId: 'catalog', moduleId: 'kubernetes-ingestor', register(reg) { + const resourceFilters: KubernetesResourceFilter[] = []; + reg.registerExtensionPoint(kubernetesIngestorExtensionPoint, { + addResourceFilter(...filters: KubernetesResourceFilter[]) { + resourceFilters.push(...filters); + }, + }); + reg.registerInit({ deps: { catalog: catalogProcessingExtensionPoint, @@ -115,6 +124,7 @@ export const catalogModuleKubernetesIngestor = createBackendModule({ resourceFetcher, urlReader, cache, + resourceFilters, ); const xrdTemplateEntityProvider = new XRDTemplateEntityProvider( diff --git a/plugins/kubernetes-ingestor/src/providers/EntityProvider.test.ts b/plugins/kubernetes-ingestor/src/providers/EntityProvider.test.ts index 958e0812..ef7b62b1 100644 --- a/plugins/kubernetes-ingestor/src/providers/EntityProvider.test.ts +++ b/plugins/kubernetes-ingestor/src/providers/EntityProvider.test.ts @@ -2386,6 +2386,126 @@ describe('KubernetesEntityProvider', () => { expect(deltaCalls[0][0].removed).toEqual([]); }); + it('should remove entities when a delta upsert becomes ineligible', async () => { + let isEligible = true; + const resourceFilter = jest.fn().mockImplementation(() => isEligible); + const provider = new KubernetesEntityProvider( + { run: jest.fn() } as any, + mockLogger, + mockConfig, + mockResourceFetcher as any, + undefined, + undefined, + [resourceFilter], + ); + + const mockConnection = { + applyMutation: jest.fn().mockResolvedValue(undefined), + }; + + await provider.connect(mockConnection as any); + (provider as any).fullSyncCompleted = true; + + mockResourceFetcher.proxyKubernetesRequest.mockResolvedValue({ + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: 'filtered-deployment', + namespace: 'default', + }, + spec: {}, + }); + + await provider.deltaUpdate({ + action: 'upsert', + apiVersion: 'apps/v1', + kind: 'Deployment', + name: 'filtered-deployment', + namespace: 'default', + clusterName: 'test-cluster', + }); + + isEligible = false; + + await provider.deltaUpdate({ + action: 'upsert', + apiVersion: 'apps/v1', + kind: 'Deployment', + name: 'filtered-deployment', + namespace: 'default', + clusterName: 'test-cluster', + }); + + expect(resourceFilter).toHaveBeenLastCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'filtered-deployment' }), + }), + { clusterName: 'test-cluster' }, + ); + const deltaCalls = mockConnection.applyMutation.mock.calls.filter( + (call: any[]) => call[0].type === 'delta', + ); + expect(deltaCalls).toHaveLength(2); + expect(deltaCalls[0][0].added.length).toBeGreaterThan(0); + expect(deltaCalls[0][0].removed).toEqual([]); + expect(deltaCalls[1][0].added).toEqual([]); + expect(deltaCalls[1][0].removed.length).toBeGreaterThan(0); + expect( + deltaCalls[1][0].removed.some( + (entry: any) => entry.entity.kind === 'System', + ), + ).toBe(false); + }); + + it('should not run filters for a disabled Crossplane delta upsert', async () => { + const resourceFilter = jest.fn().mockReturnValue(true); + const config = new ConfigReader({ + kubernetesIngestor: { + components: { enabled: true }, + crossplane: { enabled: false }, + kro: { enabled: false }, + annotationPrefix: 'terasky.backstage.io', + }, + }); + const provider = new KubernetesEntityProvider( + { run: jest.fn() } as any, + mockLogger, + config, + mockResourceFetcher as any, + undefined, + undefined, + [resourceFilter], + ); + + const mockConnection = { + applyMutation: jest.fn().mockResolvedValue(undefined), + }; + + await provider.connect(mockConnection as any); + (provider as any).fullSyncCompleted = true; + mockResourceFetcher.proxyKubernetesRequest.mockResolvedValueOnce({ + apiVersion: 'example.org/v1', + kind: 'Example', + metadata: { name: 'crossplane-resource', namespace: 'default' }, + spec: { crossplane: {} }, + }); + + await provider.deltaUpdate({ + action: 'upsert', + apiVersion: 'example.org/v1', + kind: 'Example', + name: 'crossplane-resource', + namespace: 'default', + clusterName: 'test-cluster', + }); + + expect(resourceFilter).not.toHaveBeenCalled(); + const deltaCalls = mockConnection.applyMutation.mock.calls.filter( + (call: any[]) => call[0].type === 'delta', + ); + expect(deltaCalls).toHaveLength(0); + }); + it('should perform delta delete for a regular K8s resource', async () => { const provider = new KubernetesEntityProvider( { run: jest.fn() } as any, @@ -2419,6 +2539,43 @@ describe('KubernetesEntityProvider', () => { expect(deltaCalls[0][0].removed.length).toBeGreaterThan(0); }); + it('should not apply resource filters to delta deletes', async () => { + const resourceFilter = jest.fn().mockReturnValue(false); + const provider = new KubernetesEntityProvider( + { run: jest.fn() } as any, + mockLogger, + mockConfig, + mockResourceFetcher as any, + undefined, + undefined, + [resourceFilter], + ); + + const mockConnection = { + applyMutation: jest.fn().mockResolvedValue(undefined), + }; + + await provider.connect(mockConnection as any); + (provider as any).fullSyncCompleted = true; + + await provider.deltaUpdate({ + action: 'delete', + apiVersion: 'apps/v1', + kind: 'Deployment', + name: 'filtered-deployment', + namespace: 'default', + clusterName: 'test-cluster', + }); + + expect(resourceFilter).not.toHaveBeenCalled(); + const deltaCalls = mockConnection.applyMutation.mock.calls.filter( + (call: any[]) => call[0].type === 'delta', + ); + expect(deltaCalls).toHaveLength(1); + expect(deltaCalls[0][0].added).toEqual([]); + expect(deltaCalls[0][0].removed.length).toBeGreaterThan(0); + }); + it('should handle resource fetch failure gracefully on upsert', async () => { const provider = new KubernetesEntityProvider( { run: jest.fn() } as any, diff --git a/plugins/kubernetes-ingestor/src/providers/EntityProvider.ts b/plugins/kubernetes-ingestor/src/providers/EntityProvider.ts index 52115e67..e7cbf61d 100644 --- a/plugins/kubernetes-ingestor/src/providers/EntityProvider.ts +++ b/plugins/kubernetes-ingestor/src/providers/EntityProvider.ts @@ -6,7 +6,14 @@ import { Entity, parseEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { CacheService, LoggerService, SchedulerServiceTaskRunner, UrlReaderService } from '@backstage/backend-plugin-api'; import { DefaultKubernetesResourceFetcher, ApiDefinitionFetcher } from '../services'; -import { KubernetesDataProvider, WorkloadType } from './KubernetesDataProvider'; +import { KubernetesResourceFilter } from '../types'; +import { + createBuiltInResourceEligibilityChecker, + getDisabledResourceType, + KubernetesDataProvider, + passesResourceFilters, + WorkloadType, +} from './KubernetesDataProvider'; import { Logger } from 'winston'; import { CRDDataProvider } from './CRDDataProvider'; import { XRDDataProvider } from './XRDDataProvider'; @@ -2438,6 +2445,7 @@ export class KubernetesEntityProvider implements EntityProvider { private readonly resourceFetcher: DefaultKubernetesResourceFetcher, urlReader?: UrlReaderService, private readonly cache?: CacheService, + private readonly resourceFilters: KubernetesResourceFilter[] = [], ) { this.orphanGraceRuns = config.getOptionalNumber( 'kubernetesIngestor.orphanProtection.gracePeriodRuns', @@ -2702,6 +2710,7 @@ export class KubernetesEntityProvider implements EntityProvider { this.resourceFetcher, this.config, this.logger, + this.resourceFilters, ); let compositeKindLookup: { [key: string]: any } = {}; @@ -2982,6 +2991,48 @@ export class KubernetesEntityProvider implements EntityProvider { return; } resource.clusterName = clusterName; + + const passesBuiltInEligibility = + createBuiltInResourceEligibilityChecker(this.config); + if (!passesBuiltInEligibility(resource)) { + this.logger.debug( + `Skipping delta upsert for ${kind}/${name}: resource is not eligible for ingestion`, + ); + return; + } + + const disabledResourceType = getDisabledResourceType( + resource, + isCrossplaneEnabled, + isKROEnabled, + ); + if (disabledResourceType) { + this.logger.debug( + `Skipping delta upsert for ${kind}/${name}: ${disabledResourceType} ingestion is disabled`, + ); + return; + } + + if ( + !passesResourceFilters( + resource, + { clusterName }, + this.resourceFilters, + ) + ) { + const { entities } = await this.classifyAndTranslateResource( + resource, isCrossplaneEnabled, isKROEnabled, + this.cachedCompositeKindLookup, this.cachedRgdLookup, this.cachedCrdMapping, + ); + const removable = entities.filter(entity => entity.kind !== 'System'); + if (removable.length > 0) { + await this.applyDeltaMutation([], removable); + } + this.logger.info( + `Delta upsert removed ${removable.length} entities for ineligible ${kind}/${name} from cluster ${clusterName}`, + ); + return; + } } else { // For deletes without explicit entityNames, construct a synthetic resource // enriched with enough metadata for classifyAndTranslateResource to route diff --git a/plugins/kubernetes-ingestor/src/providers/KubernetesDataProvider.test.ts b/plugins/kubernetes-ingestor/src/providers/KubernetesDataProvider.test.ts index 6b750fd8..467b9321 100644 --- a/plugins/kubernetes-ingestor/src/providers/KubernetesDataProvider.test.ts +++ b/plugins/kubernetes-ingestor/src/providers/KubernetesDataProvider.test.ts @@ -1455,5 +1455,100 @@ describe('KubernetesDataProvider', () => { expect(result['networking.io|Policy']).toBe('networkpolicies'); }); }); -}); + describe('resource filters', () => { + const workloadTypes = [ + { group: 'apps', apiVersion: 'v1', plural: 'deployments', singular: 'deployment' }, + ]; + + const resource = (name: string) => ({ + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name, namespace: 'team-a' }, + spec: {}, + }); + + const fetchWith = async (filters: any[]) => { + mockResourceFetcher.fetchResources.mockResolvedValue([ + resource('keep-me'), + resource('drop-me'), + ]); + const provider = new KubernetesDataProvider( + mockResourceFetcher as any, + mockConfig as any, + mockLogger as any, + filters, + ); + const objects = await provider.fetchForCluster('prod', workloadTypes as any); + return objects.map((o: any) => o.metadata?.name).filter(Boolean); + }; + + it('ingests everything when no filter is registered', async () => { + await expect(fetchWith([])).resolves.toEqual( + expect.arrayContaining(['keep-me', 'drop-me']), + ); + }); + + it('excludes a resource a filter rejects', async () => { + const names = await fetchWith([ + (r: any) => r.metadata?.name !== 'drop-me', + ]); + expect(names).toContain('keep-me'); + expect(names).not.toContain('drop-me'); + }); + + it('requires every filter to pass', async () => { + const names = await fetchWith([() => true, () => false]); + expect(names).toEqual([]); + }); + + it('passes the cluster name to the filter', async () => { + const seen: string[] = []; + await fetchWith([ + (_r: any, ctx: any) => { + seen.push(ctx.clusterName); + return true; + }, + ]); + expect(seen).toContain('prod'); + }); + + it.each([ + [ + 'Crossplane', + { + apiVersion: 'example.org/v1', + kind: 'Example', + metadata: { name: 'crossplane-resource', namespace: 'team-a' }, + spec: { crossplane: {} }, + }, + ], + [ + 'KRO', + { + apiVersion: 'example.org/v1', + kind: 'Example', + metadata: { + name: 'kro-resource', + namespace: 'team-a', + labels: { 'kro.run/resource-graph-definition-id': 'example' }, + }, + spec: {}, + }, + ], + ])('does not run filters for disabled %s resources', async (_type, object) => { + const resourceFilter = jest.fn().mockReturnValue(true); + mockResourceFetcher.fetchResources.mockResolvedValue([object]); + const provider = new KubernetesDataProvider( + mockResourceFetcher as any, + mockConfig as any, + mockLogger as any, + [resourceFilter], + ); + + await provider.fetchForCluster('prod', workloadTypes as any); + + expect(resourceFilter).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/kubernetes-ingestor/src/providers/KubernetesDataProvider.ts b/plugins/kubernetes-ingestor/src/providers/KubernetesDataProvider.ts index 3fdce480..a107346c 100644 --- a/plugins/kubernetes-ingestor/src/providers/KubernetesDataProvider.ts +++ b/plugins/kubernetes-ingestor/src/providers/KubernetesDataProvider.ts @@ -1,6 +1,11 @@ import { Config } from '@backstage/config'; import { LoggerService } from '@backstage/backend-plugin-api'; import { DefaultKubernetesResourceFetcher } from '../services'; +import { + KubernetesResourceFilter, + KubernetesResourceFilterContext, + KubernetesResourceFilterInput, +} from '../types'; import { XRDDataProvider } from './XRDDataProvider'; export interface WorkloadType { @@ -11,20 +16,77 @@ export interface WorkloadType { ingestAsResources?: boolean; } +export function createBuiltInResourceEligibilityChecker( + config: Config, +): (resource: KubernetesResourceFilterInput | null | undefined) => boolean { + const prefix = + config.getOptionalString('kubernetesIngestor.annotationPrefix') || + 'terasky.backstage.io'; + const onlyIngestAnnotatedResources = + config.getOptionalBoolean( + 'kubernetesIngestor.components.onlyIngestAnnotatedResources', + ) ?? false; + const excludedNamespaces = new Set( + config.getOptionalStringArray( + 'kubernetesIngestor.components.excludedNamespaces', + ) || [], + ); + + return resource => { + if (!resource?.metadata) return false; + if (resource.metadata.annotations?.[`${prefix}/exclude-from-catalog`]) { + return false; + } + if (onlyIngestAnnotatedResources) { + if (!resource.metadata.annotations?.[`${prefix}/add-to-catalog`]) { + return false; + } + } else if ( + resource.metadata.namespace !== undefined && + excludedNamespaces.has(resource.metadata.namespace) + ) { + return false; + } + return true; + }; +} + +export function getDisabledResourceType( + resource: KubernetesResourceFilterInput, + isCrossplaneEnabled: boolean, + isKROEnabled: boolean, +): 'Crossplane' | 'KRO' | undefined { + if ( + !isCrossplaneEnabled && + (resource.spec?.resourceRef || resource.spec?.crossplane) + ) { + return 'Crossplane'; + } + if ( + !isKROEnabled && + resource.metadata?.labels?.['kro.run/resource-graph-definition-id'] + ) { + return 'KRO'; + } + return undefined; +} + +export function passesResourceFilters( + resource: KubernetesResourceFilterInput, + context: KubernetesResourceFilterContext, + resourceFilters: readonly KubernetesResourceFilter[], +): boolean { + return resourceFilters.every(filter => filter(resource, context)); +} + export class KubernetesDataProvider { constructor( private readonly resourceFetcher: DefaultKubernetesResourceFetcher, private readonly config: Config, private readonly logger: LoggerService, + private readonly resourceFilters: KubernetesResourceFilter[] = [], ) {} - private getAnnotationPrefix(): string { - return ( - this.config.getOptionalString('kubernetesIngestor.annotationPrefix') || - 'terasky.backstage.io' - ); - } - /** * Builds the stable base workload-types list that is shared across all cluster iterations. * Includes defaults, custom types, and v2 XRD composite kinds. @@ -95,8 +157,8 @@ export class KubernetesDataProvider { async fetchForCluster(clusterName: string, baseWorkloadTypes: WorkloadType[]): Promise { const isCrossplaneEnabled = this.config.getOptionalBoolean('kubernetesIngestor.crossplane.enabled') ?? true; const isKROEnabled = this.config.getOptionalBoolean('kubernetesIngestor.kro.enabled') ?? false; - const onlyIngestAnnotatedResources = this.config.getOptionalBoolean('kubernetesIngestor.components.onlyIngestAnnotatedResources') ?? false; - const excludedNamespaces = new Set(this.config.getOptionalStringArray('kubernetesIngestor.components.excludedNamespaces') || []); + const passesBuiltInEligibility = + createBuiltInResourceEligibilityChecker(this.config); const ingestAllCrossplaneClaims = this.config.getOptionalBoolean('kubernetesIngestor.crossplane.claims.ingestAllClaims') ?? false; const ingestAllRGDInstances = this.config.getOptionalBoolean('kubernetesIngestor.kro.instances.ingestAllInstances') ?? false; const hasGenericCRDConfig = !!( @@ -182,32 +244,34 @@ export class KubernetesDataProvider { }), ); - const prefix = this.getAnnotationPrefix(); const allFetchedObjects = fetchedObjects .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') .map(result => result.value) .flat(); - const validObjects = allFetchedObjects.filter((resource: any) => { - if (!resource || !resource.metadata) return false; - if (resource.metadata.annotations?.[`${prefix}/exclude-from-catalog`]) return false; - if (onlyIngestAnnotatedResources) return !!resource.metadata.annotations?.[`${prefix}/add-to-catalog`]; - return !excludedNamespaces.has(resource.metadata.namespace); - }); + const validObjects = allFetchedObjects.filter((resource: any) => + passesBuiltInEligibility(resource), + ); const filteredObjects = validObjects.map(async (resource: any) => { - if (!isCrossplaneEnabled) { - if (resource.spec?.resourceRef || resource.spec?.crossplane) { - this.logger.debug(`Skipping Crossplane resource: ${resource.kind} ${resource.metadata?.name}`); - return {}; - } + const disabledResourceType = getDisabledResourceType( + resource, + isCrossplaneEnabled, + isKROEnabled, + ); + if (disabledResourceType) { + this.logger.debug(`Skipping ${disabledResourceType} resource: ${resource.kind} ${resource.metadata?.name}`); + return {}; } - if (!isKROEnabled) { - if (resource.metadata?.labels?.['kro.run/resource-graph-definition-id']) { - this.logger.debug(`Skipping KRO resource: ${resource.kind} ${resource.metadata?.name}`); - return {}; - } + if ( + !passesResourceFilters( + resource, + { clusterName }, + this.resourceFilters, + ) + ) { + return {}; } if (isCrossplaneEnabled && resource.spec?.crossplane?.compositionRef?.name) { diff --git a/plugins/kubernetes-ingestor/src/types.ts b/plugins/kubernetes-ingestor/src/types.ts index e5232427..2950123b 100644 --- a/plugins/kubernetes-ingestor/src/types.ts +++ b/plugins/kubernetes-ingestor/src/types.ts @@ -60,3 +60,37 @@ export interface ApiDefinitionResult { */ useTextReference?: boolean; } + +/** + * A Kubernetes resource as fetched from the API server, narrowed to the parts a + * filter is likely to look at. + */ +export interface KubernetesResourceFilterInput { + apiVersion?: string; + kind?: string; + metadata?: { + name?: string; + namespace?: string; + labels?: Record; + annotations?: Record; + [key: string]: any; + }; + [key: string]: any; +} + +/** Where the resource was fetched from. */ +export interface KubernetesResourceFilterContext { + clusterName: string; +} + +/** + * Decides whether a fetched resource is ingested. Returning false excludes it. + * + * Filters run after the plugin's own checks (the exclude-from-catalog annotation, + * onlyIngestAnnotatedResources, excludedNamespaces), so a filter cannot bring back a + * resource those have already rejected. + */ +export type KubernetesResourceFilter = ( + resource: KubernetesResourceFilterInput, + context: KubernetesResourceFilterContext, +) => boolean; diff --git a/site/docs/plugins/kubernetes-ingestor/backend/configure.md b/site/docs/plugins/kubernetes-ingestor/backend/configure.md index d617d654..838c2430 100644 --- a/site/docs/plugins/kubernetes-ingestor/backend/configure.md +++ b/site/docs/plugins/kubernetes-ingestor/backend/configure.md @@ -184,6 +184,67 @@ kubernetesIngestor: gracePeriodRuns: 3 ``` +## Custom Resource Filters + +`excludedNamespaces` and `onlyIngestAnnotatedResources` cover the two common cases, but +neither reaches a workload that exists in every namespace and is deployed from a chart +you do not own — an observability agent, a log shipper, a sidecar database. Annotating +those objects means editing charts belonging to someone else, and namespace exclusion +would take the namespace's real services with it. + +For that, register a filter. It is code, so it can decide on any part of the resource. + +```ts +// packages/backend/src/index.ts +backend.add(import('./modules/ingestorFilters')); +``` + +```ts +// packages/backend/src/modules/ingestorFilters.ts +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { kubernetesIngestorExtensionPoint } from '@terasky/backstage-plugin-kubernetes-ingestor'; + +const INFRA = new Set([ + 'loki-read', + 'loki-write', + 'prometheus', + 'thanos-query', + 'thanos-store', +]); + +export default createBackendModule({ + pluginId: 'catalog', + moduleId: 'kubernetes-ingestor-filters', + register(reg) { + reg.registerInit({ + deps: { ingestor: kubernetesIngestorExtensionPoint }, + async init({ ingestor }) { + ingestor.addResourceFilter(resource => !INFRA.has(resource.metadata?.name ?? '')); + }, + }); + }, +}); +``` + +A filter receives the resource and the cluster it came from, and returns `false` to +exclude it: + +```ts +type KubernetesResourceFilter = ( + resource: KubernetesResourceFilterInput, + context: { clusterName: string }, +) => boolean; +``` + +Several filters can be registered, from one module or from several; every one of them +must return `true` for a resource to be ingested. Filters run after the plugin's own +checks, so a filter cannot bring back something `excludedNamespaces` or +`exclude-from-catalog` has already rejected. + +Anything the filter excludes never becomes an entity. When an event-driven upsert makes +an existing resource fail a filter, its non-shared entities are removed immediately. +The next scheduled sync also removes entities that no longer pass the filters. + ## Cluster Authentication Requirements The Kubernetes Ingestor plugin runs on the backend and communicates directly with Kubernetes clusters. This means it requires authentication methods that work for server-to-server communication.