Skip to content

Commit 2f48500

Browse files
feloydanivilla9
authored andcommitted
feat: check connectivity with health check (podman-desktop#10076)
* feat: check cluster connectivity with health check Signed-off-by: Philippe Martin <[email protected]> * fix: abort health check on dispose Signed-off-by: Philippe Martin <[email protected]> * fix: use dispose and EventEmitter Signed-off-by: Philippe Martin <[email protected]> * fix: create Health in constructor Signed-off-by: Philippe Martin <[email protected]> * fix: remove comment on if Signed-off-by: Philippe Martin <[email protected]> * fix: call JSON.stringify only once Signed-off-by: Philippe Martin <[email protected]> * fix: do not use onReadiness event + make set check/reachable atomic Signed-off-by: Philippe Martin <[email protected]> * feat: rewrite by using events Signed-off-by: Philippe Martin <[email protected]> * fix: review Signed-off-by: Philippe Martin <[email protected]> * fix: more unit tests Signed-off-by: Philippe Martin <[email protected]>
1 parent 8177ab1 commit 2f48500

10 files changed

+1075
-3
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**********************************************************************
2+
* Copyright (C) 2024 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, KubeConfig, User } from '@kubernetes/client-node';
20+
import { AbortError, Health } from '@kubernetes/client-node';
21+
import { beforeEach, expect, test, vi } from 'vitest';
22+
23+
import { ContextHealthChecker } from './context-health-checker.js';
24+
25+
vi.mock('@kubernetes/client-node');
26+
27+
const contexts = [
28+
{
29+
name: 'context1',
30+
cluster: 'cluster1',
31+
user: 'user1',
32+
},
33+
] as Context[];
34+
35+
const clusters = [
36+
{
37+
name: 'cluster1',
38+
},
39+
] as Cluster[];
40+
41+
const users = [
42+
{
43+
name: 'user1',
44+
},
45+
] as User[];
46+
47+
const config = {
48+
contexts,
49+
clusters,
50+
users,
51+
currentContext: 'context1',
52+
} as unknown as KubeConfig;
53+
54+
beforeEach(() => {
55+
vi.mocked(Health).mockClear();
56+
});
57+
58+
test('onStateChange is fired with result of readyz if no error', async () => {
59+
const readyzMock = vi.fn();
60+
vi.mocked(Health).mockImplementation(
61+
() =>
62+
({
63+
readyz: readyzMock,
64+
}) as unknown as Health,
65+
);
66+
67+
const hc = new ContextHealthChecker(config);
68+
const onStateChangeCB = vi.fn();
69+
hc.onStateChange(onStateChangeCB);
70+
71+
readyzMock.mockResolvedValue(true);
72+
await hc.start();
73+
expect(onStateChangeCB).toHaveBeenCalledWith({ contextName: 'context1', checking: true, reachable: false });
74+
expect(onStateChangeCB).toHaveBeenCalledWith({ contextName: 'context1', checking: false, reachable: true });
75+
76+
expect(hc.getState()).toEqual({
77+
contextName: 'context1',
78+
checking: false,
79+
reachable: true,
80+
});
81+
82+
onStateChangeCB.mockReset();
83+
84+
readyzMock.mockResolvedValue(false);
85+
await hc.start();
86+
expect(onStateChangeCB).toHaveBeenCalledWith({ contextName: 'context1', checking: true, reachable: false });
87+
expect(onStateChangeCB).toHaveBeenCalledWith({ contextName: 'context1', checking: false, reachable: false });
88+
89+
expect(hc.getState()).toEqual({
90+
contextName: 'context1',
91+
checking: false,
92+
reachable: false,
93+
});
94+
});
95+
96+
test('onStateChange is not fired when readyz is rejected with an abort error', async () => {
97+
const readyzMock = vi.fn();
98+
vi.mocked(Health).mockImplementation(
99+
() =>
100+
({
101+
readyz: readyzMock,
102+
}) as unknown as Health,
103+
);
104+
105+
const hc = new ContextHealthChecker(config);
106+
const onStateChangeCB = vi.fn();
107+
hc.onStateChange(onStateChangeCB);
108+
109+
readyzMock.mockRejectedValue(new AbortError('a message'));
110+
await hc.start();
111+
expect(onStateChangeCB).toHaveBeenCalledOnce();
112+
expect(onStateChangeCB).toHaveBeenCalledWith({ contextName: 'context1', checking: true, reachable: false });
113+
expect(hc.getState()).toEqual({
114+
contextName: 'context1',
115+
checking: true,
116+
reachable: false,
117+
});
118+
});
119+
120+
test('onReadiness is called with false when readyz is rejected with a generic error', async () => {
121+
const readyzMock = vi.fn();
122+
vi.mocked(Health).mockImplementation(
123+
() =>
124+
({
125+
readyz: readyzMock,
126+
}) as unknown as Health,
127+
);
128+
129+
const hc = new ContextHealthChecker(config);
130+
const onStateChangeCB = vi.fn();
131+
hc.onStateChange(onStateChangeCB);
132+
133+
readyzMock.mockRejectedValue(new Error('a generic error'));
134+
await hc.start();
135+
expect(onStateChangeCB).toHaveBeenCalledWith({ contextName: 'context1', checking: true, reachable: false });
136+
expect(onStateChangeCB).toHaveBeenCalledWith({ contextName: 'context1', checking: false, reachable: false });
137+
expect(hc.getState()).toEqual({
138+
contextName: 'context1',
139+
checking: false,
140+
reachable: false,
141+
});
142+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**********************************************************************
2+
* Copyright (C) 2024 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 { KubeConfig } from '@kubernetes/client-node';
20+
import { AbortError, Health } from '@kubernetes/client-node';
21+
import type { Disposable } from '@podman-desktop/api';
22+
23+
import type { Event } from '../events/emitter.js';
24+
import { Emitter } from '../events/emitter.js';
25+
26+
export interface ContextHealthState {
27+
contextName: string;
28+
checking: boolean;
29+
reachable: boolean;
30+
}
31+
32+
export interface ContextHealthCheckOptions {
33+
// timeout in ms
34+
timeout?: number;
35+
}
36+
37+
// HealthChecker checks the readiness of a Kubernetes context
38+
// by requesting the readiness endpoint of its server
39+
export class ContextHealthChecker implements Disposable {
40+
#health: Health;
41+
#abortController: AbortController;
42+
43+
#onStateChange = new Emitter<ContextHealthState>();
44+
onStateChange: Event<ContextHealthState> = this.#onStateChange.event;
45+
46+
#contextName: string;
47+
48+
#currentState: ContextHealthState;
49+
50+
// builds an HealthChecker which will check the cluster of the current context of the given kubeConfig
51+
constructor(kubeConfig: KubeConfig) {
52+
this.#abortController = new AbortController();
53+
this.#health = new Health(kubeConfig);
54+
this.#contextName = kubeConfig.currentContext;
55+
this.#currentState = { contextName: this.#contextName, checking: false, reachable: false };
56+
}
57+
58+
// start checking the readiness
59+
public async start(opts?: ContextHealthCheckOptions): Promise<void> {
60+
this.#currentState = { contextName: this.#contextName, checking: true, reachable: false };
61+
this.#onStateChange.fire(this.#currentState);
62+
try {
63+
const result = await this.#health.readyz({ signal: this.#abortController.signal, timeout: opts?.timeout });
64+
this.#currentState = { contextName: this.#contextName, checking: false, reachable: result };
65+
this.#onStateChange.fire(this.#currentState);
66+
} catch (err: unknown) {
67+
if (!(err instanceof AbortError)) {
68+
this.#currentState = { contextName: this.#contextName, checking: false, reachable: false };
69+
this.#onStateChange.fire(this.#currentState);
70+
}
71+
}
72+
}
73+
74+
public dispose(): void {
75+
this.#onStateChange.dispose();
76+
this.#abortController.abort();
77+
}
78+
79+
public getState(): ContextHealthState {
80+
return this.#currentState;
81+
}
82+
}

0 commit comments

Comments
 (0)