mirror of
https://github.com/podman-desktop/podman-desktop
synced 2026-05-24 02:08:24 +00:00
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:
parent
a90f426b09
commit
b6da04ba3e
5 changed files with 660 additions and 107 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue