diff --git a/packages/main/src/plugin/kubernetes-context-state.spec.ts b/packages/main/src/plugin/kubernetes-context-state.spec.ts index 094cde08b63..3ec1603d9eb 100644 --- a/packages/main/src/plugin/kubernetes-context-state.spec.ts +++ b/packages/main/src/plugin/kubernetes-context-state.spec.ts @@ -16,13 +16,13 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import { beforeEach, afterEach, expect, test, vi } from 'vitest'; -import type { ContextGeneralState } from './kubernetes-context-state.js'; +import { beforeEach, afterEach, expect, test, vi, describe } from 'vitest'; +import { type ContextGeneralState, ContextsStates } from './kubernetes-context-state.js'; import { ContextsManager } from './kubernetes-context-state.js'; import type { ApiSenderType } from './api.js'; import * as kubeclient from '@kubernetes/client-node'; import type { ErrorCallback, KubernetesObject, ObjectCallback } from '@kubernetes/client-node'; -import { makeInformer } from '@kubernetes/client-node'; +import { makeInformer, KubeConfig } from '@kubernetes/client-node'; interface InformerEvent { delayMs: number; @@ -36,12 +36,16 @@ interface InformerErrorEvent { error: string; } +const informerStopMock = vi.fn(); + export class FakeInformer { private onCb: Map>; private offCb: Map>; private onErrorCb: Map; constructor( + private contextName: string, + private path: string, private resourcesCount: number, private connectResponse: Error | undefined, private events: InformerEvent[], @@ -72,7 +76,9 @@ export class FakeInformer { }); } } - async stop(): Promise {} + async stop(): Promise { + informerStopMock(this.contextName, this.path); + } on( verb: 'change' | 'add' | 'update' | 'delete' | 'error' | 'connect', cb: ErrorCallback | ObjectCallback, @@ -101,6 +107,13 @@ export class FakeInformer { } } +const PODS_NS1 = 1; +const PODS_NS2 = 2; +const PODS_DEFAULT = 3; +const DEPLOYMENTS_NS1 = 4; +const DEPLOYMENTS_NS2 = 5; +const DEPLOYMENTS_DEFAULT = 6; + // fakeMakeInformer describes how many resources are in the different namespaces and if cluster is reachable function fakeMakeInformer( kubeconfig: kubeclient.KubeConfig, @@ -108,6 +121,10 @@ function fakeMakeInformer( _listPromiseFn: kubeclient.ListPromise, ): kubeclient.Informer & kubeclient.ObjectCache { let connectResult: Error | undefined; + + const buildFakeInformer = (quantity: number): FakeInformer => + new FakeInformer(kubeconfig.currentContext, path, quantity, connectResult, [], []); + switch (kubeconfig.currentContext) { case 'context1': connectResult = new Error('connection error'); @@ -117,20 +134,20 @@ function fakeMakeInformer( } switch (path) { case '/api/v1/namespaces/ns1/pods': - return new FakeInformer(1, connectResult, [], []); + return buildFakeInformer(PODS_NS1); case '/api/v1/namespaces/ns2/pods': - return new FakeInformer(2, connectResult, [], []); + return buildFakeInformer(PODS_NS2); case '/api/v1/namespaces/default/pods': - return new FakeInformer(3, connectResult, [], []); + return buildFakeInformer(PODS_DEFAULT); case '/apis/apps/v1/namespaces/ns1/deployments': - return new FakeInformer(4, connectResult, [], []); + return buildFakeInformer(DEPLOYMENTS_NS1); case '/apis/apps/v1/namespaces/ns2/deployments': - return new FakeInformer(5, connectResult, [], []); + return buildFakeInformer(DEPLOYMENTS_NS2); case '/apis/apps/v1/namespaces/default/deployments': - return new FakeInformer(6, connectResult, [], []); + return buildFakeInformer(DEPLOYMENTS_DEFAULT); } - return new FakeInformer(0, connectResult, [], []); + return buildFakeInformer(0); } const apiSenderSendMock = vi.fn(); @@ -235,40 +252,42 @@ test('should send info of resources in all reachable contexts and nothing in non reachable: true, error: undefined, resources: { - pods: 3, - deployments: 6, + pods: PODS_DEFAULT, + deployments: DEPLOYMENTS_DEFAULT, }, } as ContextGeneralState); expectedMap.set('context2-1', { reachable: true, error: undefined, resources: { - pods: 1, - deployments: 4, + pods: PODS_NS1, + deployments: DEPLOYMENTS_NS1, }, } as ContextGeneralState); expectedMap.set('context2-2', { reachable: true, error: undefined, resources: { - pods: 2, - deployments: 5, + pods: PODS_NS2, + deployments: DEPLOYMENTS_NS2, }, } as ContextGeneralState); vi.advanceTimersToNextTimer(); vi.advanceTimersToNextTimer(); - expect(apiSenderSendMock).toHaveBeenCalledTimes(4); expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-contexts-general-state-update', expectedMap); expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-general-state-update', { reachable: true, error: undefined, resources: { - pods: 1, - deployments: 4, + pods: PODS_NS1, + deployments: DEPLOYMENTS_NS1, }, }); - expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-pods-update', [{}]); - expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-deployments-update', [{}, {}, {}, {}]); + expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-pods-update', Array(PODS_NS1).fill({})); + expect(apiSenderSendMock).toHaveBeenCalledWith( + 'kubernetes-current-context-deployments-update', + Array(DEPLOYMENTS_NS1).fill({}), + ); // switching to unreachable context kubeConfig.loadFromOptions({ @@ -282,7 +301,6 @@ test('should send info of resources in all reachable contexts and nothing in non await client.update(kubeConfig); vi.advanceTimersToNextTimer(); - expect(apiSenderSendMock).toHaveBeenCalledTimes(4); expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-contexts-general-state-update', expectedMap); expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-general-state-update', { reachable: false, @@ -292,11 +310,11 @@ test('should send info of resources in all reachable contexts and nothing in non deployments: 0, }, }); - // no pods/deployemnt are sent, as the context is not reachable + // no pods/deployment are sent, as the context is not reachable expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-pods-update', []); expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-deployments-update', []); - // => removing contexts, should remving clusters from sent info + // => removing context, should remove context from sent info kubeConfig.loadFromOptions({ clusters: config.clusters, users: config.users, @@ -319,32 +337,34 @@ test('should send info of resources in all reachable contexts and nothing in non reachable: true, error: undefined, resources: { - pods: 3, - deployments: 6, + pods: PODS_DEFAULT, + deployments: DEPLOYMENTS_DEFAULT, }, } as ContextGeneralState); expectedMap.set('context2-1', { reachable: true, error: undefined, resources: { - pods: 1, - deployments: 4, + pods: PODS_NS1, + deployments: DEPLOYMENTS_NS1, }, } as ContextGeneralState); vi.advanceTimersToNextTimer(); - expect(apiSenderSendMock).toHaveBeenCalledTimes(4); expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-contexts-general-state-update', expectedMap); expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-general-state-update', { reachable: true, error: undefined, resources: { - pods: 1, - deployments: 4, + pods: PODS_NS1, + deployments: DEPLOYMENTS_NS1, }, }); - expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-pods-update', [{}]); - expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-deployments-update', [{}, {}, {}, {}]); + expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-pods-update', Array(PODS_NS1).fill({})); + expect(apiSenderSendMock).toHaveBeenCalledWith( + 'kubernetes-current-context-deployments-update', + Array(DEPLOYMENTS_NS1).fill({}), + ); }); test('should write logs when connection fails', async () => { @@ -383,16 +403,18 @@ test('should write logs when connection fails', async () => { test('should send new deployment when a new one is created', async () => { vi.mocked(makeInformer).mockImplementation( ( - _kubeconfig: kubeclient.KubeConfig, + kubeconfig: kubeclient.KubeConfig, path: string, _listPromiseFn: kubeclient.ListPromise, ) => { const connectResult = undefined; switch (path) { case '/api/v1/namespaces/ns1/pods': - return new FakeInformer(0, connectResult, [], []); + return new FakeInformer(kubeconfig.currentContext, path, 0, connectResult, [], []); case '/apis/apps/v1/namespaces/ns1/deployments': return new FakeInformer( + kubeconfig.currentContext, + path, 0, connectResult, [ @@ -405,7 +427,7 @@ test('should send new deployment when a new one is created', async () => { [], ); } - return new FakeInformer(0, connectResult, [], []); + return new FakeInformer(kubeconfig.currentContext, path, 0, connectResult, [], []); }, ); const client = new ContextsManager(apiSender); @@ -487,16 +509,18 @@ test('should send new deployment when a new one is created', async () => { test('should delete deployment when deleted from context', async () => { vi.mocked(makeInformer).mockImplementation( ( - _kubeconfig: kubeclient.KubeConfig, + kubeconfig: kubeclient.KubeConfig, path: string, _listPromiseFn: kubeclient.ListPromise, ) => { const connectResult = undefined; switch (path) { case '/api/v1/namespaces/ns1/pods': - return new FakeInformer(0, connectResult, [], []); + return new FakeInformer(kubeconfig.currentContext, path, 0, connectResult, [], []); case '/apis/apps/v1/namespaces/ns1/deployments': return new FakeInformer( + kubeconfig.currentContext, + path, 0, connectResult, [ @@ -519,7 +543,7 @@ test('should delete deployment when deleted from context', async () => { [], ); } - return new FakeInformer(0, connectResult, [], []); + return new FakeInformer(kubeconfig.currentContext, path, 0, connectResult, [], []); }, ); const client = new ContextsManager(apiSender); @@ -605,16 +629,18 @@ test('should delete deployment when deleted from context', async () => { test('should update deployment when updated on context', async () => { vi.mocked(makeInformer).mockImplementation( ( - _kubeconfig: kubeclient.KubeConfig, + kubeconfig: kubeclient.KubeConfig, path: string, _listPromiseFn: kubeclient.ListPromise, ) => { const connectResult = undefined; switch (path) { case '/api/v1/namespaces/ns1/pods': - return new FakeInformer(0, connectResult, [], []); + return new FakeInformer(kubeconfig.currentContext, path, 0, connectResult, [], []); case '/apis/apps/v1/namespaces/ns1/deployments': return new FakeInformer( + kubeconfig.currentContext, + path, 0, connectResult, [ @@ -637,7 +663,7 @@ test('should update deployment when updated on context', async () => { [], ); } - return new FakeInformer(0, connectResult, [], []); + return new FakeInformer(kubeconfig.currentContext, path, 0, connectResult, [], []); }, ); const client = new ContextsManager(apiSender); @@ -723,7 +749,7 @@ test('should update deployment when updated on context', async () => { test('should send appropriate data when context becomes unreachable', async () => { vi.mocked(makeInformer).mockImplementation( ( - _kubeconfig: kubeclient.KubeConfig, + kubeconfig: kubeclient.KubeConfig, path: string, _listPromiseFn: kubeclient.ListPromise, ) => { @@ -731,6 +757,8 @@ test('should send appropriate data when context becomes unreachable', async () = switch (path) { case '/api/v1/namespaces/ns1/pods': return new FakeInformer( + kubeconfig.currentContext, + path, 0, connectResult, [], @@ -743,9 +771,9 @@ test('should send appropriate data when context becomes unreachable', async () = ], ); case '/apis/apps/v1/namespaces/ns1/deployments': - return new FakeInformer(2, connectResult, [], []); + return new FakeInformer(kubeconfig.currentContext, path, 2, connectResult, [], []); } - return new FakeInformer(0, connectResult, [], []); + return new FakeInformer(kubeconfig.currentContext, path, 0, connectResult, [], []); }, ); const client = new ContextsManager(apiSender); @@ -823,3 +851,396 @@ test('should send appropriate data when context becomes unreachable', async () = expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-pods-update', []); expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-deployments-update', []); }); + +test('createServiceInformer should send data for added resource', async () => { + vi.useFakeTimers(); + vi.mocked(makeInformer).mockImplementation( + ( + kubeconfig: kubeclient.KubeConfig, + path: string, + _listPromiseFn: kubeclient.ListPromise, + ) => { + const connectResult = undefined; + switch (path) { + case '/api/v1/namespaces/ns1/services': + return new FakeInformer(kubeconfig.currentContext, path, 1, connectResult, [], []); + } + return new FakeInformer(kubeconfig.currentContext, path, 0, connectResult, [], []); + }, + ); + const client = new ContextsManager(apiSender); + const kubeConfig = new kubeclient.KubeConfig(); + const config = { + clusters: [ + { + name: 'cluster1', + server: 'server1', + }, + ], + users: [ + { + name: 'user1', + }, + ], + contexts: [ + { + name: 'context1', + cluster: 'cluster1', + user: 'user1', + namespace: 'ns1', + }, + ], + currentContext: 'context1', + }; + kubeConfig.loadFromOptions(config); + await client.update(kubeConfig); + const ctx = kubeConfig.contexts.find(c => c.name === 'context1'); + expect(ctx).not.toBeUndefined(); + client.createServiceInformer(kubeConfig, 'ns1', ctx!); + vi.advanceTimersToNextTimer(); + vi.advanceTimersToNextTimer(); + vi.advanceTimersToNextTimer(); + const expectedMap = new Map(); + expectedMap.set('context1', { + reachable: true, + error: undefined, + resources: { + pods: 0, + deployments: 0, + }, + }); + expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-contexts-general-state-update', expectedMap); + expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-general-state-update', { + reachable: true, + resources: { + pods: 0, + deployments: 0, + }, + }); + expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-services-update', [{}]); +}); + +test('createServiceInformer should send data for deleted and updated resource', async () => { + vi.useFakeTimers(); + vi.mocked(makeInformer).mockImplementation( + ( + kubeconfig: kubeclient.KubeConfig, + path: string, + _listPromiseFn: kubeclient.ListPromise, + ) => { + const connectResult = undefined; + switch (path) { + case '/api/v1/namespaces/ns1/services': + return new FakeInformer( + kubeconfig.currentContext, + path, + 0, + connectResult, + [ + { + delayMs: 100, + verb: 'add', + object: { metadata: { uid: 'svc1' } }, + }, + { + delayMs: 200, + verb: 'add', + object: { metadata: { uid: 'svc2' } }, + }, + { + delayMs: 300, + verb: 'delete', + object: { metadata: { uid: 'svc1' } }, + }, + { + delayMs: 400, + verb: 'update', + object: { metadata: { uid: 'svc2', name: 'name2' } }, + }, + ], + [], + ); + } + return new FakeInformer(kubeconfig.currentContext, path, 0, connectResult, [], []); + }, + ); + const client = new ContextsManager(apiSender); + const kubeConfig = new kubeclient.KubeConfig(); + const config = { + clusters: [ + { + name: 'cluster1', + server: 'server1', + }, + ], + users: [ + { + name: 'user1', + }, + ], + contexts: [ + { + name: 'context1', + cluster: 'cluster1', + user: 'user1', + namespace: 'ns1', + }, + ], + currentContext: 'context1', + }; + kubeConfig.loadFromOptions(config); + await client.update(kubeConfig); + const ctx = kubeConfig.contexts.find(c => c.name === 'context1'); + expect(ctx).not.toBeUndefined(); + client.createServiceInformer(kubeConfig, 'ns1', ctx!); + vi.advanceTimersByTime(120); + expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-services-update', [ + { metadata: { uid: 'svc1' } }, + ]); + + apiSenderSendMock.mockReset(); + vi.advanceTimersByTime(100); + expect(apiSenderSendMock).toHaveBeenCalledTimes(1); // do not send general information + expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-services-update', [ + { metadata: { uid: 'svc1' } }, + { metadata: { uid: 'svc2' } }, + ]); + + apiSenderSendMock.mockReset(); + vi.advanceTimersByTime(100); + expect(apiSenderSendMock).toHaveBeenCalledTimes(1); + expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-services-update', [ + { metadata: { uid: 'svc2' } }, + ]); + + apiSenderSendMock.mockReset(); + vi.advanceTimersByTime(100); + expect(apiSenderSendMock).toHaveBeenCalledTimes(1); + expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-current-context-services-update', [ + { metadata: { uid: 'svc2', name: 'name2' } }, + ]); +}); + +test('update should not start service informer', async () => { + const makeInformerMock = vi.mocked(makeInformer); + makeInformerMock.mockImplementation( + ( + kubeconfig: kubeclient.KubeConfig, + path: string, + _listPromiseFn: kubeclient.ListPromise, + ) => { + const connectResult = undefined; + switch (path) { + case '/api/v1/namespaces/ns1/services': + return new FakeInformer(kubeconfig.currentContext, path, 0, connectResult, [], []); + } + return new FakeInformer(kubeconfig.currentContext, path, 0, connectResult, [], []); + }, + ); + const client = new ContextsManager(apiSender); + const kubeConfig = new kubeclient.KubeConfig(); + const config = { + clusters: [ + { + name: 'cluster1', + server: 'server1', + }, + ], + users: [ + { + name: 'user1', + }, + ], + contexts: [ + { + name: 'context1', + cluster: 'cluster1', + user: 'user1', + namespace: 'ns1', + }, + ], + currentContext: 'context1', + }; + kubeConfig.loadFromOptions(config); + await client.update(kubeConfig); + // makeInformer is called for pod and deployment only + expect(makeInformerMock).toHaveBeenCalledTimes(2); + expect(makeInformerMock).toHaveBeenCalledWith( + expect.any(KubeConfig), + '/apis/apps/v1/namespaces/ns1/deployments', + expect.anything(), + ); + expect(makeInformerMock).toHaveBeenCalledWith( + expect.any(KubeConfig), + '/api/v1/namespaces/ns1/pods', + expect.anything(), + ); +}); + +test('calling getCurrentContextResources should start service informer, the first time only', async () => { + vi.useFakeTimers(); + const makeInformerMock = vi.mocked(makeInformer); + makeInformerMock.mockImplementation( + ( + kubeconfig: kubeclient.KubeConfig, + path: string, + _listPromiseFn: kubeclient.ListPromise, + ) => { + return new FakeInformer(kubeconfig.currentContext, path, 0, undefined, [], []); + }, + ); + const client = new ContextsManager(apiSender); + const kubeConfig = new kubeclient.KubeConfig(); + const config = { + clusters: [ + { + name: 'cluster1', + server: 'server1', + }, + ], + users: [ + { + name: 'user1', + }, + ], + contexts: [ + { + name: 'context1', + cluster: 'cluster1', + user: 'user1', + namespace: 'ns1', + }, + ], + currentContext: 'context1', + }; + kubeConfig.loadFromOptions(config); + await client.update(kubeConfig); + vi.advanceTimersToNextTimer(); + + makeInformerMock.mockClear(); + client.getCurrentContextResources('services'); + expect(makeInformerMock).toHaveBeenCalledTimes(1); + expect(makeInformerMock).toHaveBeenCalledWith( + expect.any(KubeConfig), + '/api/v1/namespaces/ns1/services', + expect.anything(), + ); + + makeInformerMock.mockClear(); + client.getCurrentContextResources('services'); + expect(makeInformerMock).not.toHaveBeenCalled(); +}); + +test('changing context should stop service informer on previous current context', async () => { + vi.useFakeTimers(); + const makeInformerMock = vi.mocked(makeInformer); + makeInformerMock.mockImplementation( + ( + kubeconfig: kubeclient.KubeConfig, + path: string, + _listPromiseFn: kubeclient.ListPromise, + ) => { + return new FakeInformer(kubeconfig.currentContext, path, 0, undefined, [], []); + }, + ); + const client = new ContextsManager(apiSender); + const kubeConfig = new kubeclient.KubeConfig(); + const config = { + clusters: [ + { + name: 'cluster1', + server: 'server1', + }, + ], + users: [ + { + name: 'user1', + }, + ], + contexts: [ + { + name: 'context1', + cluster: 'cluster1', + user: 'user1', + namespace: 'ns1', + }, + { + name: 'context2', + cluster: 'cluster1', + user: 'user1', + namespace: 'ns2', + }, + ], + currentContext: 'context1', + }; + kubeConfig.loadFromOptions(config); + await client.update(kubeConfig); + vi.advanceTimersToNextTimer(); + + makeInformerMock.mockClear(); + + // service informer is started + client.getCurrentContextResources('services'); + expect(makeInformerMock).toHaveBeenCalledTimes(1); + expect(makeInformerMock).toHaveBeenCalledWith( + expect.any(KubeConfig), + '/api/v1/namespaces/ns1/services', + expect.anything(), + ); + + makeInformerMock.mockClear(); + + config.currentContext = 'context2'; + kubeConfig.loadFromOptions(config); + + expect(informerStopMock).not.toHaveBeenCalled(); + + await client.update(kubeConfig); + + expect(informerStopMock).toHaveBeenCalledTimes(1); + expect(informerStopMock).toHaveBeenCalledWith('context1', '/api/v1/namespaces/ns1/services'); +}); + +describe('ContextsStates tests', () => { + test('hasInformer should check if informer exists for context', () => { + const client = new ContextsStates(); + client.setInformers( + 'context1', + new Map([['pods', new FakeInformer('context1', '/path/to/resource', 0, undefined, [], [])]]), + ); + expect(client.hasInformer('context1', 'pods')).toBeTruthy(); + expect(client.hasInformer('context1', 'deployments')).toBeFalsy(); + expect(client.hasInformer('context2', 'pods')).toBeFalsy(); + expect(client.hasInformer('context2', 'deployments')).toBeFalsy(); + }); + + test('getContextsNames should return the names of contexts as array', () => { + const client = new ContextsStates(); + client.setInformers( + 'context1', + new Map([['pods', new FakeInformer('context1', '/path/to/resource', 0, undefined, [], [])]]), + ); + client.setInformers( + 'context2', + new Map([['pods', new FakeInformer('context2', '/path/to/resource', 0, undefined, [], [])]]), + ); + expect(Array.from(client.getContextsNames())).toEqual(['context1', 'context2']); + }); + + test('isReachable', () => { + const client = new ContextsStates(); + client.setInformers( + 'context1', + new Map([['pods', new FakeInformer('context1', '/path/to/resource', 0, undefined, [], [])]]), + ); + client.setInformers( + 'context2', + new Map([['pods', new FakeInformer('context2', '/path/to/resource', 0, undefined, [], [])]]), + ); + client.safeSetState('context1', state => (state.reachable = true)); + + expect(client.isReachable('context1')).toBeTruthy(); + expect(client.isReachable('context2')).toBeFalsy(); + expect(client.isReachable('context3')).toBeFalsy(); + }); +}); diff --git a/packages/main/src/plugin/kubernetes-context-state.ts b/packages/main/src/plugin/kubernetes-context-state.ts index 01a416ed915..c44eec33cd1 100644 --- a/packages/main/src/plugin/kubernetes-context-state.ts +++ b/packages/main/src/plugin/kubernetes-context-state.ts @@ -25,6 +25,8 @@ import type { V1DeploymentList, V1Pod, V1PodList, + V1Service, + V1ServiceList, } from '@kubernetes/client-node'; import { AppsV1Api, CoreV1Api, KubeConfig, makeInformer } from '@kubernetes/client-node'; import type { KubeContext } from './kubernetes-context.js'; @@ -39,10 +41,7 @@ import { import type { IncomingMessage } from 'node:http'; // ContextInternalState stores informers for a kube context -interface ContextInternalState { - podInformer?: Informer & ObjectCache; - deploymentInformer?: Informer & ObjectCache; -} +type ContextInternalState = Map & ObjectCache>; // ContextState stores information for the user about a kube context: is the cluster reachable, the number // of instances of different resources @@ -52,12 +51,20 @@ interface ContextState { resources: ContextStateResources; } -// All resources managed by podman desktop -// This is where to add new resources when adding new informers -export type ResourceName = 'pods' | 'deployments'; - // A selection of resources, to indicate the 'general' status of a context -type SelectedResourceName = 'pods' | 'deployments'; +const selectedResources = ['pods', 'deployments'] as const; + +// resources managed by podman desktop, excepted the primary ones +// This is where to add new resources when adding new informers +const secondaryResources = ['services'] as const; + +export type SelectedResourceName = (typeof selectedResources)[number]; +export type SecondaryResourceName = (typeof secondaryResources)[number]; +export type ResourceName = SelectedResourceName | SecondaryResourceName; + +function isSecondaryResourceName(value: string): value is SecondaryResourceName { + return secondaryResources.includes(value as SecondaryResourceName); +} export type ContextStateResources = { [resourceName in ResourceName]: KubernetesObject[]; @@ -104,6 +111,7 @@ type ResourcesDispatchOptions = { const dispatchAllResources: ResourcesDispatchOptions = { pods: true, deployments: true, + services: true, // add new resources here when adding new informers }; @@ -143,20 +151,37 @@ class Backoff { } } -class ContextsStates { +export class ContextsStates { private state = new Map(); private informers = new Map(); - has(name: string): boolean { + hasContext(name: string): boolean { return this.informers.has(name); } + hasInformer(context: string, resourceName: ResourceName): boolean { + const informers = this.informers.get(context); + return !!informers?.get(resourceName); + } + setInformers(name: string, informers: ContextInternalState | undefined): void { if (informers) { this.informers.set(name, informers); } } + setResourceInformer( + contextName: string, + resourceName: ResourceName, + informer: Informer & ObjectCache, + ): void { + const informers = this.informers.get(contextName); + if (!informers) { + throw new Error(`watchers for context ${contextName} not found`); + } + informers.set(resourceName, informer); + } + getContextsNames(): Iterable { return this.informers.keys(); } @@ -208,6 +233,10 @@ class ContextsStates { return []; } + isReachable(contextName: string): boolean { + return this.state.get(contextName)?.reachable ?? false; + } + safeSetState(name: string, update: (previous: ContextState) => void): void { if (!this.state.has(name)) { this.state.set(name, { @@ -216,6 +245,8 @@ class ContextsStates { resources: { pods: [], deployments: [], + services: [], + // add new resources here when adding new informers }, }); } @@ -227,11 +258,28 @@ class ContextsStates { } async dispose(name: string): Promise { - await this.informers.get(name)?.podInformer?.stop(); - await this.informers.get(name)?.deploymentInformer?.stop(); + const informers = this.informers.get(name); + if (informers) { + for (const informer of informers.values()) { + await informer.stop(); + } + } this.informers.delete(name); this.state.delete(name); } + + async disposeSecondaryInformers(contextName: string): Promise { + const informers = this.informers.get(contextName); + if (informers) { + for (const [resourceName, informer] of informers) { + if (isSecondaryResourceName(resourceName)) { + console.debug(`stop watching ${resourceName} in context ${contextName}`); + await informer?.stop(); + informers.delete(resourceName); + } + } + } + } } // the ContextsState singleton (instantiated by the kubernetes-client singleton) @@ -255,12 +303,13 @@ export class ContextsManager { // Add informers for new contexts let added = false; for (const context of this.kubeConfig.contexts) { - if (!this.states.has(context.name)) { + if (!this.states.hasContext(context.name)) { const informers = this.createKubeContextInformers(context); this.states.setInformers(context.name, informers); added = true; } } + // Delete informers for removed contexts let removed = false; for (const name of this.states.getContextsNames()) { @@ -269,6 +318,16 @@ export class ContextsManager { removed = true; } } + + // Delete secondary informers (others than pods/deployments) on non-current contexts + if (contextChanged) { + const nonCurrentContexts = this.kubeConfig.contexts.filter(ctx => ctx.name !== this.currentContext); + for (const ctx of nonCurrentContexts) { + const contextName = ctx.name; + await this.states.disposeSecondaryInformers(contextName); + } + } + if (added || removed || contextChanged) { this.dispatch({ contextsGeneralState: true, @@ -296,10 +355,28 @@ export class ContextsManager { }); const ns = context.namespace ?? 'default'; - return { - podInformer: this.createPodInformer(kc, ns, context), - deploymentInformer: this.createDeploymentInformer(kc, ns, context), - }; + const result = new Map & ObjectCache>(); + result.set('pods', this.createPodInformer(kc, ns, context)); + result.set('deployments', this.createDeploymentInformer(kc, ns, context)); + return result; + } + + startResourceInformer(contextName: string, resourceName: ResourceName): void { + const context = this.kubeConfig.contexts.find(c => c.name === contextName); + if (!context) { + throw new Error(`context ${contextName} not found`); + } + const ns = context.namespace ?? 'default'; + let informer: Informer & ObjectCache; + switch (resourceName) { + case 'services': + informer = this.createServiceInformer(this.kubeConfig, ns, context); + break; + default: + console.debug(`unable to watch ${resourceName} in context ${contextName}, as this resource is not supported`); + return; + } + this.states.setResourceInformer(contextName, resourceName, informer); } private createPodInformer(kc: KubeConfig, ns: string, context: KubeContext): Informer & ObjectCache { @@ -314,10 +391,10 @@ export class ContextsManager { backoff: new Backoff(backoffInitialValue, backoffLimit, backoffJitter), connectionDelay: connectionDelay, onAdd: obj => { - this.setStateAndDispatch(context.name, { pods: true }, state => state.resources.pods.push(obj)); + this.setStateAndDispatch(context.name, true, { pods: true }, state => state.resources.pods.push(obj)); }, onUpdate: obj => { - this.setStateAndDispatch(context.name, { pods: true }, state => { + this.setStateAndDispatch(context.name, true, { pods: true }, state => { state.resources.pods = state.resources.pods.filter(o => o.metadata?.uid !== obj.metadata?.uid); state.resources.pods.push(obj); }); @@ -325,18 +402,19 @@ export class ContextsManager { onDelete: obj => { this.setStateAndDispatch( context.name, + true, { pods: true }, state => (state.resources.pods = state.resources.pods.filter(d => d.metadata?.uid !== obj.metadata?.uid)), ); }, onReachable: reachable => { - this.setStateAndDispatch(context.name, dispatchAllResources, state => { + this.setStateAndDispatch(context.name, true, dispatchAllResources, state => { state.reachable = reachable; state.error = reachable ? undefined : state.error; // if reachable we remove error }); }, onConnectionError: error => { - this.setStateAndDispatch(context.name, dispatchAllResources, state => (state.error = error)); + this.setStateAndDispatch(context.name, true, dispatchAllResources, state => (state.error = error)); }, }); } @@ -360,10 +438,12 @@ export class ContextsManager { backoff: new Backoff(backoffInitialValue, backoffLimit, backoffJitter), connectionDelay: connectionDelay, onAdd: obj => { - this.setStateAndDispatch(context.name, { deployments: true }, state => state.resources.deployments.push(obj)); + this.setStateAndDispatch(context.name, true, { deployments: true }, state => + state.resources.deployments.push(obj), + ); }, onUpdate: obj => { - this.setStateAndDispatch(context.name, { deployments: true }, state => { + this.setStateAndDispatch(context.name, true, { deployments: true }, state => { state.resources.deployments = state.resources.deployments.filter(o => o.metadata?.uid !== obj.metadata?.uid); state.resources.deployments.push(obj); }); @@ -371,6 +451,7 @@ export class ContextsManager { onDelete: obj => { this.setStateAndDispatch( context.name, + true, { deployments: true }, state => (state.resources.deployments = state.resources.deployments.filter( @@ -381,6 +462,45 @@ export class ContextsManager { }); } + public createServiceInformer( + kc: KubeConfig, + ns: string, + context: KubeContext, + ): Informer & ObjectCache { + const k8sApi = kc.makeApiClient(CoreV1Api); + const listFn = (): Promise<{ + response: IncomingMessage; + body: V1ServiceList; + }> => k8sApi.listNamespacedService(ns); + const path = `/api/v1/namespaces/${ns}/services`; + let timer: NodeJS.Timeout | undefined; + let connectionDelay: NodeJS.Timeout | undefined; + return this.createInformer(kc, context, path, listFn, { + resource: 'services', + timer: timer, + backoff: new Backoff(backoffInitialValue, backoffLimit, backoffJitter), + connectionDelay: connectionDelay, + onAdd: obj => { + this.setStateAndDispatch(context.name, false, { services: true }, state => state.resources.services.push(obj)); + }, + onUpdate: obj => { + this.setStateAndDispatch(context.name, false, { services: true }, state => { + state.resources.services = state.resources.services.filter(o => o.metadata?.uid !== obj.metadata?.uid); + state.resources.services.push(obj); + }); + }, + onDelete: obj => { + this.setStateAndDispatch( + context.name, + false, + { services: true }, + state => + (state.resources.services = state.resources.services.filter(d => d.metadata?.uid !== obj.metadata?.uid)), + ); + }, + }); + } + private createInformer( kc: KubeConfig, context: KubeContext, @@ -490,13 +610,14 @@ export class ContextsManager { private setStateAndDispatch( name: string, + sendGeneral: boolean, options: ResourcesDispatchOptions, update: (previous: ContextState) => void, ): void { this.states.safeSetState(name, update); this.dispatch({ - contextsGeneralState: true, - currentContextGeneralState: true, + contextsGeneralState: sendGeneral, + currentContextGeneralState: sendGeneral, resources: options, }); } @@ -547,6 +668,19 @@ export class ContextsManager { } public getCurrentContextResources(resourceName: ResourceName): KubernetesObject[] { - return this.states.getCurrentContextResources(this.kubeConfig.currentContext, resourceName); + if (!this.currentContext) { + return []; + } + if (this.states.hasInformer(this.currentContext, resourceName)) { + console.debug(`already watching ${resourceName} in context ${this.currentContext}`); + return this.states.getCurrentContextResources(this.kubeConfig.currentContext, resourceName); + } + if (!this.states.isReachable(this.currentContext)) { + console.debug(`skip watching ${resourceName} in context ${this.currentContext}, as the context is not reachable`); + return []; + } + console.debug(`start watching ${resourceName} in context ${this.currentContext}`); + this.startResourceInformer(this.currentContext, resourceName); + return []; } } diff --git a/packages/renderer/src/lib/service/ServicesList.spec.ts b/packages/renderer/src/lib/service/ServicesList.spec.ts index d715629fa87..f0767a9d711 100644 --- a/packages/renderer/src/lib/service/ServicesList.spec.ts +++ b/packages/renderer/src/lib/service/ServicesList.spec.ts @@ -19,28 +19,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import '@testing-library/jest-dom/vitest'; -import { test, expect, vi, beforeEach } from 'vitest'; +import { test, expect, vi, beforeEach, beforeAll } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import ServicesList from './ServicesList.svelte'; import { get } from 'svelte/store'; import type { V1Service } from '@kubernetes/client-node'; -import { services, servicesEventStore } from '/@/stores/services'; +import { kubernetesCurrentContextServices } from '/@/stores/kubernetes-contexts-state'; -const callbacks = new Map(); -const eventEmitter = { - receive: (message: string, callback: any) => { - callbacks.set(message, callback); - }, -}; +const kubernetesGetCurrentContextResourcesMock = vi.fn(); -Object.defineProperty(global, 'window', { - value: { - events: { - receive: eventEmitter.receive, - }, - addEventListener: eventEmitter.receive, - }, - writable: true, +beforeAll(() => { + (window as any).kubernetesGetCurrentContextResources = kubernetesGetCurrentContextResourcesMock; }); beforeEach(() => { @@ -59,6 +48,7 @@ async function waitRender(customProperties: object): Promise { } test('Expect service empty screen', async () => { + kubernetesGetCurrentContextResourcesMock.mockResolvedValue([]); render(ServicesList); const noServices = screen.getByRole('heading', { name: 'No services' }); expect(noServices).toBeInTheDocument(); @@ -77,15 +67,10 @@ test('Expect services list', async () => { externalName: 'serve', }, }; - - servicesEventStore.setup(); - - const ServiceAddCallback = callbacks.get('kubernetes-service-add'); - expect(ServiceAddCallback).toBeDefined(); - await ServiceAddCallback(service); + kubernetesGetCurrentContextResourcesMock.mockResolvedValue([service]); // wait while store is populated - while (get(services).length === 0) { + while (get(kubernetesCurrentContextServices).length === 0) { await new Promise(resolve => setTimeout(resolve, 500)); } @@ -108,15 +93,10 @@ test('Expect filter empty screen', async () => { externalName: 'serve', }, }; - - servicesEventStore.setup(); - - const ServiceAddCallback = callbacks.get('kubernetes-service-add'); - expect(ServiceAddCallback).toBeDefined(); - await ServiceAddCallback(service); + kubernetesGetCurrentContextResourcesMock.mockResolvedValue([service]); // wait while store is populated - while (get(services).length === 0) { + while (get(kubernetesCurrentContextServices).length === 0) { await new Promise(resolve => setTimeout(resolve, 500)); } diff --git a/packages/renderer/src/lib/service/ServicesList.svelte b/packages/renderer/src/lib/service/ServicesList.svelte index 233a1e5be23..868f673437e 100644 --- a/packages/renderer/src/lib/service/ServicesList.svelte +++ b/packages/renderer/src/lib/service/ServicesList.svelte @@ -1,6 +1,5 @@