Start other informers lazily (#6209)

* feat: add a new createServiceInformer method
Signed-off-by: Philippe Martin <phmartin@redhat.com>

* feat: start service informer the first time services list is asked
Signed-off-by: Philippe Martin <phmartin@redhat.com>

* feat: stop service informer on previous current context when switching context
Signed-off-by: Philippe Martin <phmartin@redhat.com>

* test: use builder and constants for FakeInformer
Signed-off-by: Philippe Martin <phmartin@redhat.com>

* fix: logs messages
Signed-off-by: Philippe Martin <phmartin@redhat.com>

* refactor: oneliners
Signed-off-by: Philippe Martin <phmartin@redhat.com>

* refactor: use Map for ContextInternalState
Signed-off-by: Philippe Martin <phmartin@redhat.com>
This commit is contained in:
Philippe Martin 2024-03-02 14:14:28 +01:00 committed by GitHub
parent a90f426b09
commit b6da04ba3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 660 additions and 107 deletions

View file

@ -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<string, ObjectCallback<KubernetesObject>>;
private offCb: Map<string, ObjectCallback<KubernetesObject>>;
private onErrorCb: Map<string, ErrorCallback>;
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<void> {}
async stop(): Promise<void> {
informerStopMock(this.contextName, this.path);
}
on(
verb: 'change' | 'add' | 'update' | 'delete' | 'error' | 'connect',
cb: ErrorCallback | ObjectCallback<KubernetesObject>,
@ -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.KubernetesObject>,
): kubeclient.Informer<kubeclient.KubernetesObject> & kubeclient.ObjectCache<kubeclient.KubernetesObject> {
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<kubeclient.KubernetesObject>,
) => {
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<kubeclient.KubernetesObject>,
) => {
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<kubeclient.KubernetesObject>,
) => {
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<kubeclient.KubernetesObject>,
) => {
@ -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<kubeclient.KubernetesObject>,
) => {
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<string, ContextGeneralState>();
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<kubeclient.KubernetesObject>,
) => {
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<kubeclient.KubernetesObject>,
) => {
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<kubeclient.KubernetesObject>,
) => {
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<kubeclient.KubernetesObject>,
) => {
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();
});
});

View file

@ -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<V1Pod> & ObjectCache<V1Pod>;
deploymentInformer?: Informer<V1Deployment> & ObjectCache<V1Deployment>;
}
type ContextInternalState = Map<ResourceName, Informer<KubernetesObject> & ObjectCache<KubernetesObject>>;
// 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<string, ContextState>();
private informers = new Map<string, ContextInternalState>();
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<KubernetesObject> & ObjectCache<KubernetesObject>,
): void {
const informers = this.informers.get(contextName);
if (!informers) {
throw new Error(`watchers for context ${contextName} not found`);
}
informers.set(resourceName, informer);
}
getContextsNames(): Iterable<string> {
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<void> {
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<void> {
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<ResourceName, Informer<KubernetesObject> & ObjectCache<KubernetesObject>>();
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<KubernetesObject> & ObjectCache<KubernetesObject>;
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<V1Pod> & ObjectCache<V1Pod> {
@ -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<V1Service> & ObjectCache<V1Service> {
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<V1Service>(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<T extends KubernetesObject>(
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 [];
}
}

View file

@ -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<string, any>();
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<void> {
}
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));
}

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { filtered, searchPattern } from '../../stores/services';
import NavPage from '../ui/NavPage.svelte';
import Table from '../table/Table.svelte';
import { Column, Row } from '../table/table';
@ -20,16 +19,17 @@ import DurationColumn from '../table/DurationColumn.svelte';
import ServiceColumnType from './ServiceColumnType.svelte';
import KubernetesCurrentContextConnectionBadge from '/@/lib/ui/KubernetesCurrentContextConnectionBadge.svelte';
import KubeApplyYamlButton from '../kube/KubeApplyYAMLButton.svelte';
import { kubernetesCurrentContextServicesFiltered, serviceSearchPattern } from '/@/stores/kubernetes-contexts-state';
export let searchTerm = '';
$: searchPattern.set(searchTerm);
$: serviceSearchPattern.set(searchTerm);
let services: ServiceUI[] = [];
const serviceUtils = new ServiceUtils();
onMount(() => {
return filtered.subscribe(value => {
return kubernetesCurrentContextServicesFiltered.subscribe(value => {
services = value.map(service => serviceUtils.getServiceUI(service));
});
});
@ -148,7 +148,7 @@ const row = new Row<ServiceUI>({ selectable: _service => true });
on:update="{() => (services = services)}">
</Table>
{#if $filtered.length === 0}
{#if $kubernetesCurrentContextServicesFiltered.length === 0}
{#if searchTerm}
<FilteredEmptyScreen icon="{ServiceIcon}" kind="services" bind:searchTerm="{searchTerm}" />
{:else}

View file

@ -57,3 +57,21 @@ export const kubernetesCurrentContextDeploymentsFiltered = derived(
([$searchPattern, $deployments]) =>
$deployments.filter(deployment => findMatchInLeaves(deployment, $searchPattern.toLowerCase())),
);
// Services
export const kubernetesCurrentContextServices = readable<KubernetesObject[]>([], set => {
window.kubernetesGetCurrentContextResources('services').then(value => set(value));
window.events?.receive('kubernetes-current-context-services-update', (value: unknown) => {
set(value as KubernetesObject[]);
});
});
export const serviceSearchPattern = writable('');
// The services in the current context, filtered with `serviceSearchPattern`
export const kubernetesCurrentContextServicesFiltered = derived(
[serviceSearchPattern, kubernetesCurrentContextServices],
([$searchPattern, $services]) =>
$services.filter(service => findMatchInLeaves(service, $searchPattern.toLowerCase())),
);