Repository: kubernetes-client/javascript
Pull request #2035 (2024-11-19 10:59:42)

Add Health class to call health check endpoints on current context
Add an Health class with 2 public methods `livez` and `readyz` to check the health check endpoints of the current context.

If these endpoints are not served (status 404), the deprecated `healthz` is checked. If this endpoint `healthz` is also not served (status 404), the cluster is considered healthy, else its result is used.




Patch:
diff --git a/package-lock.json b/package-lock.json
index 88682e69..0c27bff4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -743,9 +743,9 @@
             }
         },
         "node_modules/@types/node": {
-            "version": "22.9.1",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.1.tgz",
-            "integrity": "sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==",
+            "version": "22.9.0",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
+            "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
             "dependencies": {
                 "undici-types": "~6.19.8"
             }
@@ -3903,9 +3903,9 @@
             }
         },
         "@types/node": {
-            "version": "22.9.1",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.1.tgz",
-            "integrity": "sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==",
+            "version": "22.9.0",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
+            "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
             "requires": {
                 "undici-types": "~6.19.8"
             }
diff --git a/src/health.ts b/src/health.ts
new file mode 100644
index 00000000..065b8748
--- /dev/null
+++ b/src/health.ts
@@ -0,0 +1,58 @@
+import fetch, { AbortError } from 'node-fetch';
+import { KubeConfig } from './config';
+import { RequestOptions } from 'node:https';
+
+export class Health {
+    public config: KubeConfig;
+
+    public constructor(config: KubeConfig) {
+        this.config = config;
+    }
+
+    public async readyz(opts: RequestOptions): Promise<boolean> {
+        return this.check('/readyz', opts);
+    }
+
+    public async livez(opts: RequestOptions): Promise<boolean> {
+        return this.check('/livez', opts);
+    }
+
+    private async healthz(opts: RequestOptions): Promise<boolean> {
+        return this.check('/healthz', opts);
+    }
+
+    private async check(path: string, opts: RequestOptions): Promise<boolean> {
+        const cluster = this.config.getCurrentCluster();
+        if (!cluster) {
+            throw new Error('No currently active cluster');
+        }
+
+        const requestURL = new URL(cluster.server + path);
+        const requestInit = await this.config.applyToFetchOptions(opts);
+        if (opts.signal) {
+            requestInit.signal = opts.signal;
+        }
+        requestInit.method = 'GET';
+
+        try {
+            const response = await fetch(requestURL.toString(), requestInit);
+            const status = response.status;
+            if (status === 200) {
+                return true;
+            }
+            if (status === 404) {
+                if (path === '/healthz') {
+                    // /livez/readyz return 404 and healthz also returns 404, let's consider it is live
+                    return true;
+                }
+                return this.healthz(opts);
+            }
+            return false;
+        } catch (err: unknown) {
+            if (err instanceof Error && err.name === 'AbortError') {
+                throw err;
+            }
+            throw new Error('Error occurred in health request');
+        }
+    }
+}
diff --git a/src/index.ts b/src/index.ts
index 09fc375e..cca1256c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -14,6 +14,7 @@ export * from './cp';
 export * from './patch';
 export * from './metrics';
 export * from './object';
+export * from './health';
 export { ConfigOptions, User, Cluster, Context } from './config_types';
 
 // Export AbortError and FetchError so that instanceof checks in user code will definitely use the same instances


Test patch:
diff --git a/src/health_test.ts b/src/health_test.ts
new file mode 100644
index 00000000..f79220fd
--- /dev/null
+++ b/src/health_test.ts
@@ -0,0 +1,148 @@
+import { expect } from 'chai';
+import nock from 'nock';
+
+import { KubeConfig } from './config';
+import { Health } from './health';
+import { Cluster, User } from './config_types';
+
+describe('Health', () => {
+    describe('livez', () => {
+        it('should throw an error if no current active cluster', async () => {
+            const kc = new KubeConfig();
+            const health = new Health(kc);
+            await expect(health.livez({})).to.be.rejectedWith('No currently active cluster');
+        });
+
+        it('should return true if /livez returns with status 200', async () => {
+            const kc = new KubeConfig();
+            const cluster = {
+                name: 'foo',
+                server: 'https://server.com',
+            } as Cluster;
+
+            const user = {
+                name: 'my-user',
+                password: 'some-password',
+            } as User;
+            kc.loadFromClusterAndUser(cluster, user);
+
+            const scope = nock('https://server.com').get('/livez').reply(200);
+            const health = new Health(kc);
+
+            const r = await health.livez({});
+            expect(r).to.be.true;
+            scope.done();
+        });
+
+        it('should return false if /livez returns with status 500', async () => {
+            const kc = new KubeConfig();
+            const cluster = {
+                name: 'foo',
+                server: 'https://server.com',
+            } as Cluster;
+
+            const user = {
+                name: 'my-user',
+                password: 'some-password',
+            } as User;
+            kc.loadFromClusterAndUser(cluster, user);
+
+            const scope = nock('https://server.com').get('/livez').reply(500);
+            const health = new Health(kc);
+
+            const r = await health.livez({});
+            expect(r).to.be.false;
+            scope.done();
+        });
+
+        it('should return true if /livez returns status 404 and /healthz returns status 200', async () => {
+            const kc = new KubeConfig();
+            const cluster = {
+                name: 'foo',
+                server: 'https://server.com',
+            } as Cluster;
+
+            const user = {
+                name: 'my-user',
+                password: 'some-password',
+            } as User;
+            kc.loadFromClusterAndUser(cluster, user);
+
+            const scope = nock('https://server.com');
+            scope.get('/livez').reply(404);
+            scope.get('/healthz').reply(200);
+            const health = new Health(kc);
+
+            const r = await health.livez({});
+            expect(r).to.be.true;
+            scope.done();
+        });
+
+        it('should return false if /livez returns status 404 and /healthz returns status 500', async () => {
+            const kc = new KubeConfig();
+            const cluster = {
+                name: 'foo',
+                server: 'https://server.com',
+            } as Cluster;
+
+            const user = {
+                name: 'my-user',
+                password: 'some-password',
+            } as User;
+            kc.loadFromClusterAndUser(cluster, user);
+
+            const scope = nock('https://server.com');
+            scope.get('/livez').reply(404);
+            scope.get('/healthz').reply(500);
+            const health = new Health(kc);
+
+            const r = await health.livez({});
+            expect(r).to.be.false;
+            scope.done();
+        });
+
+        it('should return true if both /livez and /healthz return status 404', async () => {
+            const kc = new KubeConfig();
+            const cluster = {
+                name: 'foo',
+                server: 'https://server.com',
+            } as Cluster;
+
+            const user = {
+                name: 'my-user',
+                password: 'some-password',
+            } as User;
+            kc.loadFromClusterAndUser(cluster, user);
+
+            const scope = nock('https://server.com');
+            scope.get('/livez').reply(404);
+            scope.get('/healthz').reply(200);
+            const health = new Health(kc);
+
+            const r = await health.livez({});
+            expect(r).to.be.true;
+            scope.done();
+        });
+
+        it('should throw an error when fetch throws an error', async () => {
+            const kc = new KubeConfig();
+            const cluster = {
+                name: 'foo',
+                server: 'https://server.com',
+            } as Cluster;
+
+            const user = {
+                name: 'my-user',
+                password: 'some-password',
+            } as User;
+            kc.loadFromClusterAndUser(cluster, user);
+
+            const scope = nock('https://server.com');
+            scope.get('/livez').replyWithError(new Error('an error'));
+            const health = new Health(kc);
+
+            await expect(health.livez({})).to.be.rejectedWith('Error occurred in health request');
+            scope.done();
+        });
+    });
+});