Skip to content

Commit

Permalink
feat: add a new createServiceInformer method
Browse files Browse the repository at this point in the history
Signed-off-by: Philippe Martin <[email protected]>
  • Loading branch information
feloy committed Feb 29, 2024
1 parent 0322eea commit b59397f
Show file tree
Hide file tree
Showing 2 changed files with 225 additions and 12 deletions.
170 changes: 167 additions & 3 deletions packages/main/src/plugin/kubernetes-context-state.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,6 @@ test('should send info of resources in all reachable contexts and nothing in non
} 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,
Expand All @@ -282,7 +281,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,
Expand Down Expand Up @@ -333,7 +331,6 @@ test('should send info of resources in all reachable contexts and nothing in non
} 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,
Expand Down Expand Up @@ -823,3 +820,170 @@ 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(1, connectResult, [], []);
}
return new FakeInformer(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(
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(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' } },
]);
});
67 changes: 58 additions & 9 deletions packages/main/src/plugin/kubernetes-context-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -54,7 +56,7 @@ interface ContextState {

// All resources managed by podman desktop
// This is where to add new resources when adding new informers
export type ResourceName = 'pods' | 'deployments';
export type ResourceName = 'pods' | 'deployments' | 'services';

// A selection of resources, to indicate the 'general' status of a context
type SelectedResourceName = 'pods' | 'deployments';
Expand Down Expand Up @@ -104,6 +106,7 @@ type ResourcesDispatchOptions = {
const dispatchAllResources: ResourcesDispatchOptions = {
pods: true,
deployments: true,
services: true,
// add new resources here when adding new informers
};

Expand Down Expand Up @@ -216,6 +219,8 @@ class ContextsStates {
resources: {
pods: [],
deployments: [],
services: [],
// add new resources here when adding new informers
},
});
}
Expand Down Expand Up @@ -314,29 +319,30 @@ 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);
});
},
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));
},
});
}
Expand All @@ -360,17 +366,20 @@ 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);
});
},
onDelete: obj => {
this.setStateAndDispatch(
context.name,
true,
{ deployments: true },
state =>
(state.resources.deployments = state.resources.deployments.filter(
Expand All @@ -381,6 +390,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,
Expand Down Expand Up @@ -490,13 +538,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,
});
}
Expand Down

0 comments on commit b59397f

Please sign in to comment.