diff --git a/src/health.ts b/src/health.ts new file mode 100644 index 0000000000..065b874875 --- /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 { + return this.check('/readyz', opts); + } + + public async livez(opts: RequestOptions): Promise { + return this.check('/livez', opts); + } + + private async healthz(opts: RequestOptions): Promise { + return this.check('/healthz', opts); + } + + private async check(path: string, opts: RequestOptions): Promise { + 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/health_test.ts b/src/health_test.ts new file mode 100644 index 0000000000..f79220fd9a --- /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(); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 09fc375e10..cca1256c31 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