diff --git a/internal/server/lookup_errors_test.go b/internal/server/lookup_errors_test.go index 0addedc326..330b1bca30 100644 --- a/internal/server/lookup_errors_test.go +++ b/internal/server/lookup_errors_test.go @@ -35,7 +35,6 @@ var lookupAssignment = regexp.MustCompile(`const\s+([A-Za-z_$][\w$]*)\s*=\s*` + // // This set may SHRINK. It must never GROW. var lookupErrorBaseline = map[string]string{ - "ServiceRenderer.tsx :: data: endpointSlices, isLoading: endpointSlicesLoading": "pre-existing: endpoint slices behind the Service Endpoints section", "CNPGDeclarativeRenderer.tsx :: data": "the name resolver degrades to plain text instead of a link, " + "which is its documented fallback; the list beside it carries its own error note", } diff --git a/packages/k8s-ui/src/components/resources/renderers/EndpointSliceOwnership.test.tsx b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceOwnership.test.tsx new file mode 100644 index 0000000000..54da11a7a8 --- /dev/null +++ b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceOwnership.test.tsx @@ -0,0 +1,12 @@ +// @vitest-environment jsdom +import {renderToStaticMarkup} from 'react-dom/server' +import {expect,it} from 'vitest' +import {EndpointSliceRenderer} from './EndpointSliceRenderer' +it('displays an unverified owner name and UID without a forward navigation link',()=>{ + const html=renderToStaticMarkup({}} />) + expect(html).toContain('Declared Owner Service');expect(html).toContain('deleted-owner');expect([...new DOMParser().parseFromString(html, 'text/html').querySelectorAll('button')].some(button => button.textContent === 'old')).toBe(false) +}) +it('retains standard label navigation independently from owner metadata',()=>{ + const html=renderToStaticMarkup({}} />) + expect(html).toContain('published');expect([...new DOMParser().parseFromString(html, 'text/html').querySelectorAll('button')].some(button => button.textContent === 'published')).toBe(true) +}) diff --git a/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx index 4e4a2a4940..0c67375982 100644 --- a/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx @@ -3,6 +3,7 @@ import { clsx } from 'clsx' import { Section, PropertyList, Property } from '../../ui/drawer-components' import { Badge } from '../../ui/Badge' import type { ResourceRef } from '../../../types' +import { endpointSliceServiceAssociation } from '../../../utils/endpoint-slices' interface EndpointSliceRendererProps { data: any @@ -25,10 +26,10 @@ function endpointTargetLabel(endpoint: any): string | null { export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendererProps) { const metadata = data.metadata || {} - const labels = metadata.labels || {} const endpoints = data.endpoints || [] const ports = data.ports || [] - const serviceName = labels['kubernetes.io/service-name'] + const serviceAssociation = endpointSliceServiceAssociation(data) + const serviceName = serviceAssociation?.name const readyCount = endpoints.filter(isEndpointReady).length const addresses = endpointAddressCount(endpoints) @@ -42,18 +43,19 @@ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendere {serviceName && ( onNavigate({ kind: 'Service', namespace: metadata.namespace, name: serviceName })} + onClick={() => onNavigate({ kind: 'Service', group: '', namespace: metadata.namespace, name: serviceName })} > {serviceName} ) : serviceName} /> )} + {serviceAssociation?.source === 'ownerReference' && } diff --git a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx new file mode 100644 index 0000000000..b241d28494 --- /dev/null +++ b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx @@ -0,0 +1,36 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { expect, it } from 'vitest' +import { ServiceRenderer } from './ServiceRenderer' +const data = { metadata: { name: 'web', namespace: 'team' }, spec: { selector: { app: 'web' } } } +const props = { data, onCopy: () => {}, copied: null } +it('renders queried slices for selected Services without inferring health from template selection', () => { + const html = renderToStaticMarkup() + expect(html).toContain('EndpointSlices') + expect(html).toContain('published') + expect(html).toContain('0/1') +}) +it('separates unavailable, loading, ready-empty and unprovided inventory', () => { + expect(renderToStaticMarkup()).not.toContain('EndpointSlices') + const error = renderToStaticMarkup() + expect(error).toContain('EndpointSlice inventory unavailable') + expect(error).not.toContain('No EndpointSlices found') + expect(renderToStaticMarkup()).toContain('Loading EndpointSlices') + expect(renderToStaticMarkup()).toContain('No EndpointSlices found') +}) +it('does not show slice inventory for ExternalName even when supplied', () => { + expect(renderToStaticMarkup()).not.toContain('EndpointSlices') +}) + +it('marks unlabeled owned slices without describing them as published endpoints', () => { + const owned = { apiVersion: 'discovery.k8s.io/v1', kind: 'EndpointSlice', metadata: { namespace: 'team', name: 'owned', ownerReferences: [{ apiVersion: 'v1', kind: 'Service', name: 'web', uid: 'web-now' }] } } + const html = renderToStaticMarkup() + expect(html).toContain('Owner reference only') + expect(html).toContain('ownership does not establish published Service endpoints') +}) + +it('filters raw namespace inventory in the shared renderer, independently from the host label set', () => { + const owned = { apiVersion:'discovery.k8s.io/v1',kind:'EndpointSlice',metadata:{name:'owned',namespace:'team',ownerReferences:[{apiVersion:'v1',kind:'Service',name:'web',uid:'web-now'}]} } + const replaced = {...owned,metadata:{...owned.metadata,name:'replaced',ownerReferences:[{apiVersion:'v1',kind:'Service',name:'web',uid:'web-before'}]}} + const html=renderToStaticMarkup() + expect(html).toContain('owned');expect(html).not.toContain('replaced') +}) diff --git a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx index f2074b260d..fed159ff76 100644 --- a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx @@ -1,6 +1,7 @@ -import { type ReactNode } from 'react' +import { useMemo, type ReactNode } from 'react' import { Globe, Clock, Radio } from 'lucide-react' import { Section, PropertyList, Property, KeyValueBadgeList, CopyHandler, AlertBanner } from '../../ui/drawer-components' +import { endpointSliceServiceAssociation, endpointSliceMatchesService } from '../../../utils/endpoint-slices' import type { ResourceRef } from '../../../types' export interface ServicePortRenderProps { @@ -17,7 +18,10 @@ interface ServiceRendererProps { onCopy: CopyHandler copied: string | null endpointSlices?: any[] + endpointSliceInventory?: any[] endpointSlicesLoading?: boolean + endpointSlicesEnabled?: boolean + endpointSlicesError?: string onNavigate?: (ref: ResourceRef) => void renderPortAction?: (props: ServicePortRenderProps) => ReactNode renderPortPanel?: (props: ServicePortRenderProps) => ReactNode @@ -38,7 +42,13 @@ function endpointSliceReadyClass(ready: number, total: number): string { return 'status-unhealthy' } -export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpointSlicesLoading, onNavigate, renderPortAction, renderPortPanel }: ServiceRendererProps) { +export function ServiceRenderer({ data, onCopy, copied, endpointSlices: suppliedEndpointSlices, endpointSliceInventory, endpointSlicesLoading, endpointSlicesEnabled, endpointSlicesError, onNavigate, renderPortAction, renderPortPanel }: ServiceRendererProps) { + // The host passes raw namespace inventory; association semantics stay inside + // the shared UI. Older published consumers only know endpointSlices, which + // continues to carry their existing label-filtered set. + const endpointSlices = useMemo(() => endpointSliceInventory + ? endpointSliceInventory.filter(slice => endpointSliceMatchesService(slice, data)) + : suppliedEndpointSlices, [endpointSliceInventory, suppliedEndpointSlices, data]) const spec = data.spec || {} const ports = spec.ports || [] const lbIngress = data.status?.loadBalancer?.ingress || [] @@ -111,10 +121,12 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint )} - {hasNoSelector && !isExternalName && ( -
+ {!isExternalName && (hasNoSelector || endpointSlicesEnabled) && ( +
{endpointSlicesLoading ? (
Loading EndpointSlices…
+ ) : endpointSlicesError ? ( + ) : endpointSlices && endpointSlices.length > 0 ? (
{endpointSlices.map((slice: any) => { @@ -122,6 +134,7 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint const endpoints = slice.endpoints || [] const ready = endpointSliceReadyCount(slice) const addresses = endpointSliceAddressCount(slice) + const ownerOnly = endpointSliceServiceAssociation(slice)?.source === 'ownerReference' return (
{ready}/{endpoints.length} ready diff --git a/packages/k8s-ui/src/utils/endpoint-slices.test.ts b/packages/k8s-ui/src/utils/endpoint-slices.test.ts new file mode 100644 index 0000000000..bb941de2d6 --- /dev/null +++ b/packages/k8s-ui/src/utils/endpoint-slices.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from 'vitest' +import { endpointSliceMatchesService, endpointSliceServiceAssociation } from './endpoint-slices' +const service = { metadata: { namespace: 'team', name: 'web', uid: 'web-now' } } +const owner = (name='web', uid='web-now', controller=false) => ({ apiVersion: 'v1', kind: 'Service', name, uid, controller }) +const slice = (metadata: object) => ({ apiVersion: 'discovery.k8s.io/v1', kind: 'EndpointSlice', metadata: { namespace: 'team', ...metadata } }) + +it('uses the standard label as primary, independently of metadata ownership', () => { + const value = slice({ labels: { 'kubernetes.io/service-name': 'web' }, ownerReferences: [owner('other', 'old', true)] }) + expect(endpointSliceMatchesService(value, service)).toBe(true) + expect(endpointSliceServiceAssociation(value)?.source).toBe('label') + expect(endpointSliceMatchesService(slice({ labels: { 'kubernetes.io/service-name': 'other' }, ownerReferences: [owner()] }), service)).toBe(false) + expect(endpointSliceMatchesService(slice({ namespace: 'foreign', labels: { 'kubernetes.io/service-name': 'web' } }), service)).toBe(false) +}) + +it('resolves unambiguous exact core owners and rejects observed replacement UIDs', () => { + expect(endpointSliceMatchesService(slice({ ownerReferences: [owner()] }), service)).toBe(true) + expect(endpointSliceMatchesService(slice({ ownerReferences: [owner('web', 'deleted')] }), service)).toBe(false) + expect(endpointSliceServiceAssociation(slice({ ownerReferences: [{ ...owner(), apiVersion: 'custom.example.io/v1' }] }))).toBeNull() + expect(endpointSliceServiceAssociation(slice({ ownerReferences: [owner(), owner('other','other-now')] }))).toBeNull() + expect(endpointSliceMatchesService(slice({ ownerReferences: [owner('other','other-now'), owner('web','web-now',true)] }), service)).toBe(true) + expect(endpointSliceMatchesService(slice({ ownerReferences: [owner(), owner()] }), service)).toBe(true) +}) + +it('does not interpret non-EndpointSlice API groups or empty source identity', () => { + expect(endpointSliceServiceAssociation({ ...slice({ labels: { 'kubernetes.io/service-name': 'web' } }), apiVersion: 'custom.example.io/v1' })).toBeNull() + expect(endpointSliceServiceAssociation(slice({ namespace: '', ownerReferences: [owner()] }))).toBeNull() + expect(endpointSliceServiceAssociation(slice({}))).toBeNull() +}) diff --git a/packages/k8s-ui/src/utils/endpoint-slices.ts b/packages/k8s-ui/src/utils/endpoint-slices.ts new file mode 100644 index 0000000000..cc49aed73a --- /dev/null +++ b/packages/k8s-ui/src/utils/endpoint-slices.ts @@ -0,0 +1,45 @@ +import type { ResourceRef } from '../types' +import { apiVersionToGroup } from './navigation' + +interface ServiceMetadata { + namespace?: string + name?: string + uid?: string +} +interface EndpointSliceIdentity { + apiVersion?: string + kind?: string + metadata?: ServiceMetadata & { + labels?: Record + ownerReferences?: Array<{ apiVersion: string; kind: string; name: string; uid: string; controller?: boolean }> + } +} + +export interface EndpointSliceServiceAssociation extends ResourceRef { + source: 'label' | 'ownerReference' + uid?: string +} + +/** Labels define the Service's published slice set. Exact core owner metadata + * can identify an unlabeled slice, but ambiguous owners establish no association. + */ +export function endpointSliceServiceAssociation(slice: EndpointSliceIdentity): EndpointSliceServiceAssociation | null { + const metadata = slice.metadata + if (slice.kind !== 'EndpointSlice' || apiVersionToGroup(slice.apiVersion) !== 'discovery.k8s.io' || !metadata?.namespace) return null + const label = metadata.labels?.['kubernetes.io/service-name'] + if (label) return { kind: 'Service', group: '', namespace: metadata.namespace, name: label, source: 'label' } + const owners = (metadata.ownerReferences ?? []).filter(ref => ref.apiVersion === 'v1' && ref.kind === 'Service' && ref.name && ref.uid) + const controllers = owners.filter(ref => ref.controller === true) + const candidates = controllers.length ? controllers : owners + const distinct = new Map(candidates.map(ref => [`${ref.name}/${ref.uid}`, ref])) + if (distinct.size !== 1) return null + const owner = distinct.values().next().value! + return { kind: 'Service', group: '', namespace: metadata.namespace, name: owner.name, source: 'ownerReference', uid: owner.uid } +} + +export function endpointSliceMatchesService(slice: EndpointSliceIdentity, service: { metadata?: ServiceMetadata }): boolean { + const association = endpointSliceServiceAssociation(slice) + const metadata = service.metadata + return !!association && !!metadata && association.namespace === metadata.namespace && association.name === metadata.name + && (association.source !== 'ownerReference' || !metadata.uid || association.uid === metadata.uid) +} diff --git a/web/src/components/resources/renderers/ServiceRenderer.test.tsx b/web/src/components/resources/renderers/ServiceRenderer.test.tsx new file mode 100644 index 0000000000..5b75385cfe --- /dev/null +++ b/web/src/components/resources/renderers/ServiceRenderer.test.tsx @@ -0,0 +1,33 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, expect, it, vi } from 'vitest' +import { ServiceRenderer } from './ServiceRenderer' +const mock = vi.hoisted(() => ({ query: {} as any, reads: [] as any[] })) +vi.mock('../../../api/client', () => ({ useResources: (...args: any[]) => { mock.reads.push(args); return mock.query } })) +vi.mock('../../../contexts/CapabilitiesContext', () => ({ useNamespacedCapabilities: () => ({ canPortForward: false }), useIsLocalDeployment: () => true })) +vi.mock('../../portforward/PortForwardButton', () => ({ PortForwardInlineButton: () => null })) +vi.mock('../../curl/ServiceCurlButton', () => ({ CurlButton: () => null, CurlPanel: () => null, isHttpishPort: () => false, defaultScheme: () => '', defaultPathForPort: () => '' })) +const service = { apiVersion: 'v1', kind: 'Service', metadata: { name: 'web', namespace: 'team', uid: 'web-now' }, spec: { selector: { app: 'web' } } } +const slice = (name: string, metadata: object) => ({ apiVersion: 'discovery.k8s.io/v1', kind: 'EndpointSlice', metadata: { namespace: 'team', name, ...metadata } }) +const render = (data: Parameters[0]['data'] = service) => renderToStaticMarkup( {}} copied={null} />) +beforeEach(() => { mock.query={};mock.reads=[] }) +it('queries slices for selected Services and includes exact label or owner associations', () => { + mock.query={ data:[ + slice('labeled',{labels:{'kubernetes.io/service-name':'web'}}), + slice('owned',{ownerReferences:[{apiVersion:'v1',kind:'Service',name:'web',uid:'web-now'}]}), + slice('other',{labels:{'kubernetes.io/service-name':'different'}}), + slice('replaced',{ownerReferences:[{apiVersion:'v1',kind:'Service',name:'web',uid:'web-deleted'}]}), + ] } + const html=render() + expect(mock.reads[0]).toEqual(['endpointslices','team','discovery.k8s.io',{enabled:true,refetchInterval:30000}]) + expect(html).toContain('labeled');expect(html).toContain('owned');expect(html).not.toContain('replaced');expect(html).not.toContain('different') +}) +it('exposes inventory read failures rather than empty results', () => { + mock.query={error:new Error('Read denied'),data:[]} + const html=render() + expect(html).toContain('EndpointSlice inventory unavailable');expect(html).not.toContain('No EndpointSlices found') +}) +it('disables EndpointSlice inventory for ExternalName', () => { + const data={...service,spec:{type:'ExternalName',externalName:'example.org'}} + const html=render(data) + expect(mock.reads[0][3].enabled).toBe(false);expect(html).not.toContain('EndpointSlices') +}) diff --git a/web/src/components/resources/renderers/ServiceRenderer.tsx b/web/src/components/resources/renderers/ServiceRenderer.tsx index 4a69dcc301..d33fd879d6 100644 --- a/web/src/components/resources/renderers/ServiceRenderer.tsx +++ b/web/src/components/resources/renderers/ServiceRenderer.tsx @@ -39,20 +39,28 @@ export function ServiceRenderer({ data, onCopy, copied, onNavigate }: ServiceRen const shouldLoadEndpointSlices = Boolean( namespace && serviceName && - spec.type !== 'ExternalName' && - (!spec.selector || Object.keys(spec.selector).length === 0) + spec.type !== 'ExternalName' ) - const { data: endpointSlices, isLoading: endpointSlicesLoading } = useResources( + const { data: endpointSlices, isLoading: endpointSlicesLoading, error: endpointSlicesError } = useResources( 'endpointslices', namespace, 'discovery.k8s.io', { enabled: shouldLoadEndpointSlices, refetchInterval: 30000 } ) const matchingEndpointSlices = useMemo( - () => (endpointSlices || []).filter((slice: any) => slice.metadata?.labels?.['kubernetes.io/service-name'] === serviceName), - [endpointSlices, serviceName] + () => (endpointSlices || []).filter((slice: any) => slice.metadata?.namespace === namespace && slice.metadata?.labels?.['kubernetes.io/service-name'] === serviceName), + [endpointSlices, namespace, serviceName] ) + // Additive options are ignored by the actual older pinned SDK, which still + // receives its label-filtered endpointSlices. Keep the host source compatible + // with that published component type as well as its runtime imports. + const endpointSliceOptions = { + endpointSliceInventory: endpointSlices, + endpointSlicesEnabled: shouldLoadEndpointSlices, + endpointSlicesError: endpointSlicesError?.message, + } + return ( ( <>