Skip to content

Commit 4c441d2

Browse files
committed
fix: use makeInformer
1 parent 1f065d5 commit 4c441d2

File tree

3 files changed

+288
-249
lines changed

3 files changed

+288
-249
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/**********************************************************************
2+
* Copyright (C) 2024, 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import type { Cluster, Context, ListWatch, User, V1ObjectMeta } from '@kubernetes/client-node';
20+
import { ApiException, DELETE, ERROR, KubeConfig, UPDATE } from '@kubernetes/client-node';
21+
import * as kubernetesClient from '@kubernetes/client-node';
22+
import { expect, test, vi } from 'vitest';
23+
24+
import { KubeConfigSingleContext } from './kubeconfig-single-context.js';
25+
import { ResourceInformer } from './resource-informer.js';
26+
27+
vi.mock('@kubernetes/client-node', async () => {
28+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
29+
const actual = await vi.importActual<typeof import('@kubernetes/client-node')>('@kubernetes/client-node');
30+
return {
31+
...actual,
32+
makeInformer: vi.fn(),
33+
};
34+
});
35+
36+
interface MyResource {
37+
apiVersion?: string;
38+
kind?: string;
39+
metadata?: V1ObjectMeta;
40+
}
41+
42+
const contexts = [
43+
{
44+
name: 'context1',
45+
cluster: 'cluster1',
46+
user: 'user1',
47+
namespace: 'ns1',
48+
},
49+
{
50+
name: 'context2',
51+
cluster: 'cluster2',
52+
user: 'user2',
53+
},
54+
] as Context[];
55+
56+
const clusters = [
57+
{
58+
name: 'cluster1',
59+
},
60+
{
61+
name: 'cluster2',
62+
},
63+
] as Cluster[];
64+
65+
const users = [
66+
{
67+
name: 'user1',
68+
},
69+
{
70+
name: 'user2',
71+
},
72+
] as User[];
73+
74+
const kcWith2contexts = {
75+
contexts,
76+
clusters,
77+
users,
78+
} as unknown as KubeConfig;
79+
80+
test('ResourceInformer should fire onCacheUpdated event with countChanged to false when resources are updated', async () => {
81+
const kc = new KubeConfig();
82+
kc.loadFromOptions(kcWith2contexts);
83+
const listFn = vi.fn();
84+
const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!);
85+
const items = [
86+
{ metadata: { name: 'res1', namespace: 'ns1' } },
87+
{ metadata: { name: 'res2', namespace: 'ns1' } },
88+
] as MyResource[];
89+
listFn.mockResolvedValue({ items: items });
90+
const informer = new ResourceInformer<MyResource>({
91+
kubeconfig,
92+
path: '/a/path',
93+
listFn,
94+
kind: 'MyResource',
95+
plural: 'myresources',
96+
});
97+
const getListWatchOnMock = vi.fn();
98+
vi.mocked(kubernetesClient.makeInformer).mockReturnValue({
99+
on: getListWatchOnMock,
100+
start: vi.fn().mockResolvedValue({}),
101+
} as unknown as ListWatch<MyResource>);
102+
getListWatchOnMock.mockImplementation((event: string, f: (obj: MyResource) => void) => {
103+
if (event === UPDATE) {
104+
f({ metadata: { ...items[0]!.metadata, resourceVersion: '2' } });
105+
}
106+
});
107+
const onCacheUpdatedCB = vi.fn();
108+
informer.onCacheUpdated(onCacheUpdatedCB);
109+
informer.start();
110+
await vi.waitFor(() => {
111+
expect(onCacheUpdatedCB).toHaveBeenCalledWith({ kubeconfig, resourceName: 'myresources', countChanged: false });
112+
});
113+
});
114+
115+
test('ResourceInformer should fire onOffline event is informer fails', async () => {
116+
const kc = new KubeConfig();
117+
kc.loadFromOptions(kcWith2contexts);
118+
const listFn = vi.fn();
119+
const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!);
120+
const informer = new ResourceInformer<MyResource>({
121+
kubeconfig,
122+
path: '/a/path',
123+
listFn,
124+
kind: 'MyResource',
125+
plural: 'myresources',
126+
});
127+
const onCB = vi.fn();
128+
vi.mocked(kubernetesClient.makeInformer).mockReturnValue({
129+
on: onCB,
130+
start: vi.fn().mockResolvedValue({}),
131+
} as unknown as ListWatch<MyResource>);
132+
const onOfflineCB = vi.fn();
133+
onCB.mockImplementation((e: string, f) => {
134+
if (e === ERROR) {
135+
f(new ApiException(500, 'an error', {}, {}));
136+
}
137+
});
138+
informer.onOffline(onOfflineCB);
139+
informer.start();
140+
expect(onOfflineCB).toHaveBeenCalledWith({
141+
kubeconfig,
142+
offline: true,
143+
reason: `Error: HTTP-Code: 500
144+
Message: an error
145+
Body: {}
146+
Headers: {}`,
147+
resourceName: 'myresources',
148+
});
149+
});
150+
151+
test('reconnect should do nothing if there is no error', async () => {
152+
const kc = new KubeConfig();
153+
kc.loadFromOptions(kcWith2contexts);
154+
const listFn = vi.fn();
155+
const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!);
156+
const informer = new ResourceInformer<MyResource>({
157+
kubeconfig,
158+
path: '/a/path',
159+
listFn,
160+
kind: 'MyResource',
161+
plural: 'myresources',
162+
});
163+
const onCB = vi.fn();
164+
const startMock = vi.fn().mockResolvedValue({});
165+
vi.mocked(kubernetesClient.makeInformer).mockReturnValue({
166+
on: onCB,
167+
start: startMock,
168+
} as unknown as ListWatch<MyResource>);
169+
const onOfflineCB = vi.fn();
170+
onCB.mockImplementation((e: string, _f) => {
171+
if (e === ERROR) {
172+
// do nothing
173+
}
174+
});
175+
informer.onOffline(onOfflineCB);
176+
informer.start();
177+
expect(startMock).toHaveBeenCalledOnce();
178+
startMock.mockClear();
179+
informer.reconnect();
180+
expect(startMock).not.toHaveBeenCalled();
181+
});
182+
183+
test('reconnect should call start again if there is an error', async () => {
184+
const kc = new KubeConfig();
185+
kc.loadFromOptions(kcWith2contexts);
186+
const listFn = vi.fn();
187+
const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!);
188+
const informer = new ResourceInformer<MyResource>({
189+
kubeconfig,
190+
path: '/a/path',
191+
listFn,
192+
kind: 'MyResource',
193+
plural: 'myresources',
194+
});
195+
const onCB = vi.fn();
196+
const startMock = vi.fn().mockResolvedValue({});
197+
vi.mocked(kubernetesClient.makeInformer).mockReturnValue({
198+
on: onCB,
199+
start: startMock,
200+
} as unknown as ListWatch<MyResource>);
201+
const onOfflineCB = vi.fn();
202+
onCB.mockImplementation((e: string, f) => {
203+
if (e === ERROR) {
204+
f('an error');
205+
}
206+
});
207+
informer.onOffline(onOfflineCB);
208+
informer.start();
209+
expect(startMock).toHaveBeenCalledOnce();
210+
startMock.mockClear();
211+
informer.reconnect();
212+
expect(startMock).toHaveBeenCalled();
213+
});
214+
215+
test('informer is stopped when disposed', async () => {
216+
const kc = new KubeConfig();
217+
kc.loadFromOptions(kcWith2contexts);
218+
const listFn = vi.fn();
219+
const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!);
220+
const informer = new ResourceInformer<MyResource>({
221+
kubeconfig,
222+
path: '/a/path',
223+
listFn,
224+
kind: 'MyResource',
225+
plural: 'myresources',
226+
});
227+
const onCB = vi.fn();
228+
const startMock = vi.fn().mockResolvedValue({});
229+
const stopMock = vi.fn().mockResolvedValue({});
230+
vi.mocked(kubernetesClient.makeInformer).mockReturnValue({
231+
on: onCB,
232+
start: startMock,
233+
stop: stopMock,
234+
} as unknown as ListWatch<MyResource>);
235+
const onOfflineCB = vi.fn();
236+
informer.onOffline(onOfflineCB);
237+
informer.start();
238+
expect(startMock).toHaveBeenCalledOnce();
239+
startMock.mockClear();
240+
informer.dispose();
241+
expect(stopMock).toHaveBeenCalled();
242+
});
243+
244+
test('ResourceInformer should fire onObjectDeleted event when a resource is deleted', async () => {
245+
const kc = new KubeConfig();
246+
kc.loadFromOptions(kcWith2contexts);
247+
const listFn = vi.fn();
248+
const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!);
249+
const items = [
250+
{ metadata: { name: 'res1', namespace: 'ns1' } },
251+
{ metadata: { name: 'res2', namespace: 'ns1' } },
252+
] as MyResource[];
253+
listFn.mockResolvedValue({ items: items });
254+
const informer = new ResourceInformer<MyResource>({
255+
kubeconfig,
256+
path: '/a/path',
257+
listFn,
258+
kind: 'MyResource',
259+
plural: 'myresources',
260+
});
261+
const getListWatchOnMock = vi.fn();
262+
vi.mocked(kubernetesClient.makeInformer).mockReturnValue({
263+
on: getListWatchOnMock,
264+
start: vi.fn().mockResolvedValue({}),
265+
} as unknown as ListWatch<MyResource>);
266+
getListWatchOnMock.mockImplementation((event: string, f: (obj: MyResource) => void) => {
267+
if (event === DELETE) {
268+
f(items[0]!);
269+
}
270+
});
271+
const onCacheUpdatedCB = vi.fn();
272+
informer.onObjectDeleted(onCacheUpdatedCB);
273+
informer.start();
274+
await vi.waitFor(() => {
275+
expect(onCacheUpdatedCB).toHaveBeenCalledWith({
276+
kubeconfig,
277+
resourceName: 'myresources',
278+
name: 'res1',
279+
namespace: 'ns1',
280+
});
281+
});
282+
});

0 commit comments

Comments
 (0)