Repository navigation
Show EndpointSlice inventories for selected Services #1997
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nadaverell
wants to merge
6
commits into
main
Choose a base branch
from
feature/relationship-service-slices
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
b15638f
Expose EndpointSlice inventories for selected Services with exact ass…
nadaverell 515a8e0
test: type Service renderer inputs according to its props
nadaverell d339ef3
ui: distinguish unlabeled slice ownership from published endpoints
nadaverell 20810df
Remove the resolved Service lookup-error allowance
nadaverell 5f6ed67
Keep EndpointSlice ownership verification inside the shared UI
nadaverell 0b35649
Keep additive Service options compatible with the pinned SDK type
nadaverell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
12 changes: 12 additions & 0 deletions
12
packages/k8s-ui/src/components/resources/renderers/EndpointSliceOwnership.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<EndpointSliceRenderer data={{apiVersion:'discovery.k8s.io/v1',kind:'EndpointSlice',metadata:{name:'slice',namespace:'app',ownerReferences:[{apiVersion:'v1',kind:'Service',name:'old',uid:'deleted-owner'}]}}} onNavigate={()=>{}} />) | ||
| 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(<EndpointSliceRenderer data={{apiVersion:'discovery.k8s.io/v1',kind:'EndpointSlice',metadata:{name:'slice',namespace:'app',labels:{'kubernetes.io/service-name':'published'}}}} onNavigate={()=>{}} />) | ||
| expect(html).toContain('published');expect([...new DOMParser().parseFromString(html, 'text/html').querySelectorAll('button')].some(button => button.textContent === 'published')).toBe(true) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
36 changes: 36 additions & 0 deletions
36
packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlices={[{ metadata: { name: 'published' }, endpoints: [{ addresses: ['10.0.0.1'], conditions: { ready: false } }] }]} />) | ||
| expect(html).toContain('EndpointSlices') | ||
| expect(html).toContain('published') | ||
| expect(html).toContain('0/1') | ||
| }) | ||
| it('separates unavailable, loading, ready-empty and unprovided inventory', () => { | ||
| expect(renderToStaticMarkup(<ServiceRenderer {...props} />)).not.toContain('EndpointSlices') | ||
| const error = renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlices={[]} endpointSlicesError="Read denied" />) | ||
| expect(error).toContain('EndpointSlice inventory unavailable') | ||
| expect(error).not.toContain('No EndpointSlices found') | ||
| expect(renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlicesLoading />)).toContain('Loading EndpointSlices') | ||
| expect(renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlices={[]} />)).toContain('No EndpointSlices found') | ||
| }) | ||
| it('does not show slice inventory for ExternalName even when supplied', () => { | ||
| expect(renderToStaticMarkup(<ServiceRenderer {...props} data={{ ...data, spec: { type: 'ExternalName', externalName: 'example.org' } }} endpointSlicesEnabled endpointSlices={[]} />)).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(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlices={[owned]} />) | ||
| 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(<ServiceRenderer {...props} data={{...data,metadata:{...data.metadata,uid:'web-now'}}} endpointSlicesEnabled endpointSlices={[]} endpointSliceInventory={[owned,replaced]} />) | ||
| expect(html).toContain('owned');expect(html).not.toContain('replaced') | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string> | ||
| 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) | ||
| } |
33 changes: 33 additions & 0 deletions
33
web/src/components/resources/renderers/ServiceRenderer.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof ServiceRenderer>[0]['data'] = service) => renderToStaticMarkup(<ServiceRenderer data={data} onCopy={() => {}} 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') | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.