Skip to content

Commit

Permalink
feat: dispatch resources count (podman-desktop#10455)
Browse files Browse the repository at this point in the history
Signed-off-by: Philippe Martin <[email protected]>
  • Loading branch information
feloy authored Jan 16, 2025
1 parent b255e5b commit 2f0874b
Show file tree
Hide file tree
Showing 15 changed files with 516 additions and 30 deletions.
23 changes: 23 additions & 0 deletions packages/api/src/kubernetes-resource-count.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

export interface ResourceCount {
contextName: string;
resourceName: string;
count: number;
}
5 changes: 5 additions & 0 deletions packages/main/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js';
import type { ContextPermission } from '/@api/kubernetes-contexts-permissions.js';
import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states.js';
import type { ForwardConfig, ForwardOptions } from '/@api/kubernetes-port-forward-model.js';
import type { ResourceCount } from '/@api/kubernetes-resource-count.js';
import type { ManifestCreateOptions, ManifestInspectInfo, ManifestPushOptions } from '/@api/manifest-info.js';
import type { NetworkInspectInfo } from '/@api/network-info.js';
import type { NotificationCard, NotificationCardOptions } from '/@api/notification.js';
Expand Down Expand Up @@ -2602,6 +2603,10 @@ export class PluginSystem {
return kubernetesClient.getContextsPermissions();
});

this.ipcHandle('kubernetes:getResourcesCount', async (_listener): Promise<ResourceCount[]> => {
return kubernetesClient.getResourcesCount();
});

const kubernetesExecCallbackMap = new Map<
number,
{ onStdIn: (data: string) => void; onResize: (columns: number, rows: number) => void }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,16 @@ test('ContextResourceRegistry', () => {
expect(registry.get('context1', 'resource1')).toEqual('value1');

registry.set('context1', 'resource2', 'value2');
expect(registry.getAll()).toEqual(['value1', 'value2']);
expect(registry.getAll()).toEqual([
{
contextName: 'context1',
resourceName: 'resource1',
value: 'value1',
},
{
contextName: 'context1',
resourceName: 'resource2',
value: 'value2',
},
]);
});
20 changes: 14 additions & 6 deletions packages/main/src/plugin/kubernetes/context-resource-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

export interface Details<T> {
contextName: string;
resourceName: string;
value: T;
}

// ContextResourceRegistry stores objects of type T for contexts and resources
export class ContextResourceRegistry<T> {
#registry: Map<string, Map<string, T>> = new Map();
Expand All @@ -31,11 +37,13 @@ export class ContextResourceRegistry<T> {
return this.#registry.get(context)?.get(resource);
}

getAll(): T[] {
const result: T[] = [];
for (const contexts of this.#registry.values()) {
result.push(...contexts.values());
}
return result;
getAll(): Details<T>[] {
return Array.from(this.#registry.entries()).flatMap(([contextName, resources]) => {
return Array.from(resources.entries()).map(([resourceName, value]) => ({
contextName,
resourceName,
value,
}));
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import type { Cluster, KubernetesObject } from '@kubernetes/client-node';
import type { Cluster, KubernetesObject, ObjectCache } from '@kubernetes/client-node';
import { KubeConfig } from '@kubernetes/client-node';
import { beforeEach, describe, expect, test, vi } from 'vitest';

Expand All @@ -27,7 +27,7 @@ import { ContextsManagerExperimental } from './contexts-manager-experimental.js'
import { KubeConfigSingleContext } from './kubeconfig-single-context.js';
import type { ResourceFactory } from './resource-factory.js';
import { ResourceFactoryBase } from './resource-factory.js';
import type { ResourceInformer } from './resource-informer.js';
import type { CacheUpdatedEvent, ResourceInformer } from './resource-informer.js';

const onCacheUpdatedMock = vi.fn();
const onOfflineMock = vi.fn();
Expand Down Expand Up @@ -288,7 +288,94 @@ describe('HealthChecker pass and PermissionsChecker resturns a value', async ()
}),
);
await manager.update(kc);
expect(startMock).toHaveBeenCalledTimes(2); // on resource1 for each context
expect(startMock).toHaveBeenCalledTimes(2); // on resource1 for each context (resource2 does not have informer declared)
});

describe('informer is started', async () => {
let kcSingle1: KubeConfigSingleContext;
let kcSingle2: KubeConfigSingleContext;
beforeEach(async () => {
kcSingle1 = new KubeConfigSingleContext(kc, context1);
kcSingle2 = new KubeConfigSingleContext(kc, context2);
let call = 0;
onreachableMock.mockImplementation(f => {
call++;
f({
kubeConfig: call === 1 ? kcSingle1 : kcSingle2,
contextName: call === 1 ? 'context1' : 'context2',
checking: false,
reachable: call === 1,
} as ContextHealthState);
});
onPermissionResultMock.mockImplementation(f =>
f({
kubeConfig: call === 1 ? kcSingle1 : kcSingle2,
resources: ['resource1', 'resource2'],
permitted: true,
}),
);
});

test('cache updated with a change on resource count', async () => {
onCacheUpdatedMock.mockImplementation(f => {
f({
kubeconfig: kcSingle1,
resourceName: 'resource1',
countChanged: true,
} as CacheUpdatedEvent);
});
const onResourceUpdatedCB = vi.fn();
const onResourceCountUpdatedCB = vi.fn();
manager.onResourceUpdated(onResourceUpdatedCB);
manager.onResourceCountUpdated(onResourceCountUpdatedCB);
await manager.update(kc);
// called twice: on resource1 for each context
expect(startMock).toHaveBeenCalledTimes(2);
expect(onResourceUpdatedCB).toHaveBeenCalledTimes(2);
expect(onResourceCountUpdatedCB).toHaveBeenCalledTimes(2);
});

test('cache updated without a change on resource count', async () => {
onCacheUpdatedMock.mockImplementation(f => {
f({
kubeconfig: kcSingle1,
resourceName: 'resource1',
countChanged: false,
} as CacheUpdatedEvent);
});
const onResourceUpdatedCB = vi.fn();
const onResourceCountUpdatedCB = vi.fn();
manager.onResourceUpdated(onResourceUpdatedCB);
manager.onResourceCountUpdated(onResourceCountUpdatedCB);
await manager.update(kc);
// called twice: on resource1 for each context
expect(startMock).toHaveBeenCalledTimes(2);
expect(onResourceUpdatedCB).toHaveBeenCalledTimes(2);
expect(onResourceCountUpdatedCB).not.toHaveBeenCalled();
});

test('getResourcesCount', async () => {
const listMock = vi.fn();
startMock.mockReturnValue({
list: listMock,
get: vi.fn(),
} as ObjectCache<KubernetesObject>);
listMock.mockReturnValue([{}, {}]);
await manager.update(kc);
const counts = manager.getResourcesCount();
expect(counts).toEqual([
{
contextName: 'context1',
resourceName: 'resource1',
count: 2,
},
{
contextName: 'context2',
resourceName: 'resource1',
count: 2,
},
]);
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import type { KubeConfig, KubernetesObject, ObjectCache } from '@kubernetes/client-node';

import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states.js';
import type { ResourceCount } from '/@api/kubernetes-resource-count.js';

import type { Event } from '../events/emitter.js';
import { Emitter } from '../events/emitter.js';
Expand Down Expand Up @@ -62,6 +63,12 @@ export class ContextsManagerExperimental {
#onContextDelete = new Emitter<DispatcherEvent>();
onContextDelete: Event<DispatcherEvent> = this.#onContextDelete.event;

#onResourceUpdated = new Emitter<{ contextName: string; resourceName: string }>();
onResourceUpdated: Event<{ contextName: string; resourceName: string }> = this.#onResourceUpdated.event;

#onResourceCountUpdated = new Emitter<{ contextName: string; resourceName: string }>();
onResourceCountUpdated: Event<{ contextName: string; resourceName: string }> = this.#onResourceCountUpdated.event;

constructor() {
this.#resourceFactoryHandler = new ResourceFactoryHandler();
for (const resourceFactory of this.getResourceFactories()) {
Expand Down Expand Up @@ -123,8 +130,17 @@ export class ContextsManagerExperimental {
}
const informer = factory.informer.createInformer(event.kubeConfig);
this.#informers.set(contextName, resource, informer);
informer.onCacheUpdated((_e: CacheUpdatedEvent) => {
/* send event to dispatcher */
informer.onCacheUpdated((e: CacheUpdatedEvent) => {
this.#onResourceUpdated.fire({
contextName: e.kubeconfig.getKubeConfig().currentContext,
resourceName: e.resourceName,
});
if (e.countChanged) {
this.#onResourceCountUpdated.fire({
contextName: e.kubeconfig.getKubeConfig().currentContext,
resourceName: e.resourceName,
});
}
});
informer.onOffline((_e: OfflineEvent) => {
/* send event to dispatcher */
Expand Down Expand Up @@ -180,6 +196,14 @@ export class ContextsManagerExperimental {
return result;
}

getResourcesCount(): ResourceCount[] {
return this.#objectCaches.getAll().map(informer => ({
contextName: informer.contextName,
resourceName: informer.resourceName,
count: informer.value.list().length,
}));
}

getContextsGeneralState(): Map<string, ContextGeneralState> {
return new Map<string, ContextGeneralState>();
}
Expand Down Expand Up @@ -232,7 +256,7 @@ export class ContextsManagerExperimental {
// disposeAllInformers disposes all informers and removes them from registry
private disposeAllInformers(): void {
for (const informer of this.#informers.getAll()) {
informer.dispose();
informer.value.dispose();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ test('ContextsStatesDispatcher should call updateHealthStates when onContextHeal
onContextDelete: vi.fn(),
getHealthCheckersStates: vi.fn(),
getPermissions: vi.fn(),
onResourceCountUpdated: vi.fn(),
} as unknown as ContextsManagerExperimental;
const apiSender: ApiSenderType = {
send: vi.fn(),
Expand Down Expand Up @@ -61,6 +62,7 @@ test('ContextsStatesDispatcher should call updatePermissions when onContextPermi
onContextDelete: vi.fn(),
getHealthCheckersStates: vi.fn(),
getPermissions: vi.fn(),
onResourceCountUpdated: vi.fn(),
} as unknown as ContextsManagerExperimental;
const apiSender: ApiSenderType = {
send: vi.fn(),
Expand All @@ -87,6 +89,7 @@ test('ContextsStatesDispatcher should call updateHealthStates and updatePermissi
onContextDelete: onContextDeleteMock,
getHealthCheckersStates: vi.fn(),
getPermissions: vi.fn(),
onResourceCountUpdated: vi.fn(),
} as unknown as ContextsManagerExperimental;
const apiSender: ApiSenderType = {
send: vi.fn(),
Expand Down Expand Up @@ -239,18 +242,21 @@ test('getContextsPermissions should return the values as an array', () => {
});

test('updatePermissions should call apiSender.send with kubernetes-contexts-permissions', () => {
const manager: ContextsManagerExperimental = {
onContextHealthStateChange: vi.fn(),
onContextPermissionResult: vi.fn(),
onContextDelete: vi.fn(),
getHealthCheckersStates: vi.fn(),
getPermissions: vi.fn(),
} as unknown as ContextsManagerExperimental;
const manager: ContextsManagerExperimental = {} as ContextsManagerExperimental;
const apiSender: ApiSenderType = {
send: vi.fn(),
} as unknown as ApiSenderType;
const dispatcher = new ContextsStatesDispatcher(manager, apiSender);
vi.spyOn(dispatcher, 'getContextsPermissions').mockReturnValue([]);
dispatcher.updatePermissions();
expect(vi.mocked(apiSender.send)).toHaveBeenCalledWith('kubernetes-contexts-permissions');
});

test('updateResourcesCount should call apiSender.send with kubernetes-resources-count', () => {
const manager: ContextsManagerExperimental = {} as ContextsManagerExperimental;
const apiSender: ApiSenderType = {
send: vi.fn(),
} as unknown as ApiSenderType;
const dispatcher = new ContextsStatesDispatcher(manager, apiSender);
dispatcher.updateResourcesCount();
expect(vi.mocked(apiSender.send)).toHaveBeenCalledWith('kubernetes-resources-count');
});
10 changes: 10 additions & 0 deletions packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js';
import type { ContextPermission } from '/@api/kubernetes-contexts-permissions.js';
import type { ResourceCount } from '/@api/kubernetes-resource-count.js';

import type { ApiSenderType } from '../api.js';
import type { ContextHealthState } from './context-health-checker.js';
Expand All @@ -38,6 +39,7 @@ export class ContextsStatesDispatcher {
this.updateHealthStates();
this.updatePermissions();
});
this.manager.onResourceCountUpdated(() => this.updateResourcesCount());
}

updateHealthStates(): void {
Expand Down Expand Up @@ -70,4 +72,12 @@ export class ContextsStatesDispatcher {
}));
});
}

updateResourcesCount(): void {
this.apiSender.send(`kubernetes-resources-count`);
}

getResourcesCount(): ResourceCount[] {
return this.manager.getResourcesCount();
}
}
8 changes: 8 additions & 0 deletions packages/main/src/plugin/kubernetes/kubernetes-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js';
import type { ContextPermission } from '/@api/kubernetes-contexts-permissions.js';
import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states.js';
import type { ForwardConfig, ForwardOptions } from '/@api/kubernetes-port-forward-model.js';
import type { ResourceCount } from '/@api/kubernetes-resource-count.js';
import type { V1Route } from '/@api/openshift-types.js';

import type { ApiSenderType } from '../api.js';
Expand Down Expand Up @@ -1691,4 +1692,11 @@ export class KubernetesClient {
}
return this.contextsStatesDispatcher.getContextsPermissions();
}

public getResourcesCount(): ResourceCount[] {
if (!this.contextsStatesDispatcher) {
throw new Error('contextsStatesDispatcher is undefined. This should not happen in Kubernetes experimental');
}
return this.contextsStatesDispatcher.getResourcesCount();
}
}
Loading

0 comments on commit 2f0874b

Please sign in to comment.