From f7a859e7a75752483dabf942872f0859bfc46ac2 Mon Sep 17 00:00:00 2001 From: "Marvin A. Ruder" Date: Wed, 12 Jul 2023 23:10:24 +0200 Subject: [PATCH] Extend status API with health checks for connected services (#314) Signed-off-by: Marvin A. Ruder --- .../controllers/StatusController.live.test.ts | 4 ++-- .../src/controllers/StatusController.ts | 8 +++++++- packages/backend/src/db/client.ts | 11 +++++++++++ packages/backend/src/openapi/components.ts | 12 ++++++++++++ .../src/openapi/paths/statusEndpoint.ts | 13 +++++++++---- .../src/openapi/responses/serverError.ts | 14 ++++++++++++++ .../backend/src/openapi/responses/success.ts | 12 +++--------- packages/backend/src/redis/redis.ts | 18 ++++++++++++++++++ .../backend/src/signal/__mocks__/signalBase.ts | 7 +++++++ packages/backend/src/signal/signalBase.ts | 16 +++++++++++++++- .../backend/src/utils/__mocks__/webdriver.ts | 6 ++++++ packages/backend/src/utils/cron.ts | 2 +- packages/backend/src/utils/webdriver.ts | 14 +++++++++++++- packages/backend/test/live.test.ts | 1 + 14 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 packages/backend/src/utils/__mocks__/webdriver.ts diff --git a/packages/backend/src/controllers/StatusController.live.test.ts b/packages/backend/src/controllers/StatusController.live.test.ts index 6f6776340..fa0a05dd9 100644 --- a/packages/backend/src/controllers/StatusController.live.test.ts +++ b/packages/backend/src/controllers/StatusController.live.test.ts @@ -6,10 +6,10 @@ export const suiteName = "Status API"; export const tests: LiveTestSuite = []; tests.push({ - testName: "returns status “operational”", + testName: "returns status “healthy”", testFunction: async () => { const res = await supertest.get(`/api${statusEndpointPath}`); expect(res.status).toBe(200); - expect(res.body.status).toBe("operational"); + expect(res.body.status).toBe("healthy"); }, }); diff --git a/packages/backend/src/controllers/StatusController.ts b/packages/backend/src/controllers/StatusController.ts index c70645664..ca7913007 100644 --- a/packages/backend/src/controllers/StatusController.ts +++ b/packages/backend/src/controllers/StatusController.ts @@ -1,6 +1,10 @@ import { Request, Response } from "express"; import { statusEndpointPath } from "@rating-tracker/commons"; import Router from "../utils/router.js"; +import { redisIsReady } from "../redis/redis.js"; +import { prismaIsReady } from "../db/client.js"; +import { seleniumIsReady } from "../utils/webdriver.js"; +import { signalIsReadyOrUnused } from "../signal/signalBase.js"; /** * This class is responsible for providing a trivial status response whenever the backend API is up and running. @@ -18,6 +22,8 @@ export class StatusController { accessRights: 0, }) get(_: Request, res: Response) { - res.status(200).json({ status: "operational" }).end(); + Promise.all([redisIsReady(), prismaIsReady(), seleniumIsReady(), signalIsReadyOrUnused()]) + .then(() => res.status(200).json({ status: "healthy" }).end()) + .catch((e) => res.status(500).json({ status: "unhealthy", details: e.message }).end()); } } diff --git a/packages/backend/src/db/client.ts b/packages/backend/src/db/client.ts index c81e41798..428428692 100644 --- a/packages/backend/src/db/client.ts +++ b/packages/backend/src/db/client.ts @@ -5,4 +5,15 @@ import { PrismaClient } from "../../prisma/client"; */ const client = new PrismaClient(); +/** + * Checks if the database is reachable. + * + * @returns {Promise} A promise that resolves when the database is reachable, or rejects with an error if it is + * not. + */ +export const prismaIsReady = (): Promise => + client.$executeRaw`SELECT null` + .then(() => Promise.resolve()) + .catch((e) => Promise.reject(new Error("Database is not reachable: " + e.message))); + export default client; diff --git a/packages/backend/src/openapi/components.ts b/packages/backend/src/openapi/components.ts index f241c48da..9585164bb 100644 --- a/packages/backend/src/openapi/components.ts +++ b/packages/backend/src/openapi/components.ts @@ -387,5 +387,17 @@ export const components: OpenAPIV3.ComponentsObject = { }, required: ["message"], }, + Status: { + type: "object", + properties: { + status: { + type: "string", + }, + details: { + type: "string", + }, + }, + required: ["status"], + }, }, }; diff --git a/packages/backend/src/openapi/paths/statusEndpoint.ts b/packages/backend/src/openapi/paths/statusEndpoint.ts index 5ae9373e8..92b7ea78c 100644 --- a/packages/backend/src/openapi/paths/statusEndpoint.ts +++ b/packages/backend/src/openapi/paths/statusEndpoint.ts @@ -1,16 +1,21 @@ import { OpenAPIV3 } from "express-openapi-validator/dist/framework/types.js"; -import { okOperational } from "../responses/success.js"; +import { okHealthy } from "../responses/success.js"; +import { internalServerErrorServerUnhealthy } from "../responses/serverError.js"; /** - * Returns a JSON object with the status “operational” if online. + * Returns a JSON object with the status “healthy” if online and able to connect to all services, or “unhealthy” + * otherwise. */ const get: OpenAPIV3.OperationObject = { tags: ["Status API"], operationId: "status", summary: "Status API", - description: "Returns a JSON object with the status “operational” if online.", + description: + "Returns a JSON object with the status “healthy” if online and able to connect to all services, or " + + "“unhealthy” otherwise.", responses: { - "200": okOperational, + "200": okHealthy, + "500": internalServerErrorServerUnhealthy, }, }; diff --git a/packages/backend/src/openapi/responses/serverError.ts b/packages/backend/src/openapi/responses/serverError.ts index fb14db8ec..a680cefe9 100644 --- a/packages/backend/src/openapi/responses/serverError.ts +++ b/packages/backend/src/openapi/responses/serverError.ts @@ -14,6 +14,20 @@ export const internalServerError: OpenAPIV3.ResponseObject = { }, }; +/** + * A response with a 500 Internal Server Error status code and a Status object body. + */ +export const internalServerErrorServerUnhealthy: OpenAPIV3.ResponseObject = { + description: "Internal Server Error – Server Unhealthy", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Status", + }, + }, + }, +}; + /** * A response with a 501 Not Implemented status code and an Error object body. */ diff --git a/packages/backend/src/openapi/responses/success.ts b/packages/backend/src/openapi/responses/success.ts index b9f8b1db9..d5da4e5c8 100644 --- a/packages/backend/src/openapi/responses/success.ts +++ b/packages/backend/src/openapi/responses/success.ts @@ -129,20 +129,14 @@ export const okWatchlistSummary: OpenAPIV3.ResponseObject = { }; /** - * A response with a 200 OK status code and an object containing a status string. + * A response with a 200 OK status code and a Status object body. */ -export const okOperational: OpenAPIV3.ResponseObject = { +export const okHealthy: OpenAPIV3.ResponseObject = { description: "OK", content: { "application/json": { schema: { - type: "object", - properties: { - status: { - type: "string", - }, - }, - required: ["status"], + $ref: "#/components/schemas/Status", }, }, }, diff --git a/packages/backend/src/redis/redis.ts b/packages/backend/src/redis/redis.ts index 5cb0fc4cd..9133956f2 100644 --- a/packages/backend/src/redis/redis.ts +++ b/packages/backend/src/redis/redis.ts @@ -22,4 +22,22 @@ redis.on( ); await redis.connect(); +/** + * Checks if the Redis server is reachable. + * + * @returns {Promise} A promise that resolves when the Redis server is reachable, or rejects with an error if it + * is not. + */ +export const redisIsReady = (): Promise => + redis.isReady + ? redis + .ping() + .then((pong) => + pong === "PONG" + ? Promise.resolve() + : /* c8 ignore next */ Promise.reject(new Error("Redis is not reachable: server responded with " + pong)), + ) + : /* c8 ignore next */ Promise.reject(new Error("Redis is not ready")); +// The errors only occurs when Redis server is not available, which is difficult to reproduce. + export default redis; diff --git a/packages/backend/src/signal/__mocks__/signalBase.ts b/packages/backend/src/signal/__mocks__/signalBase.ts index 7817ea3e3..856fe3be8 100644 --- a/packages/backend/src/signal/__mocks__/signalBase.ts +++ b/packages/backend/src/signal/__mocks__/signalBase.ts @@ -1,3 +1,10 @@ +/** + * A mock of the signalIsReadyOrUnused function. Always resolves to "No Signal URL provided". + * + * @returns {Promise} A promise that resolves to "No Signal URL provided". + */ +export const signalIsReadyOrUnused = (): Promise => Promise.resolve("No Signal URL provided"); + /** * A mock storage of sent Signal messages. */ diff --git a/packages/backend/src/signal/signalBase.ts b/packages/backend/src/signal/signalBase.ts index 99fb64b07..8633c5f4a 100644 --- a/packages/backend/src/signal/signalBase.ts +++ b/packages/backend/src/signal/signalBase.ts @@ -1,8 +1,22 @@ // This file is not tested because tests must not depend on a running Signal Client instance -import axios from "axios"; +import axios, { AxiosError } from "axios"; import chalk from "chalk"; import logger, { PREFIX_SIGNAL } from "../utils/logger.js"; +/** + * Checks if the Signal Client instance is reachable. + * + * @returns {Promise} A promise that resolves when the Signal Client instance is reachable, or rejects + * with an error if it is not. + */ +export const signalIsReadyOrUnused = (): Promise => + process.env.SIGNAL_URL && process.env.SIGNAL_SENDER + ? axios + .get(`${process.env.SIGNAL_URL}/v1/health`, { timeout: 1000 }) + .then((res) => (res.status === 204 ? Promise.resolve() : Promise.reject(new Error("Signal is not ready")))) + .catch((e: AxiosError) => Promise.reject(new Error("Signal is not reachable: " + e.message))) + : Promise.resolve("No Signal URL provided"); + /** * Send a message to a list of recipients. * diff --git a/packages/backend/src/utils/__mocks__/webdriver.ts b/packages/backend/src/utils/__mocks__/webdriver.ts new file mode 100644 index 000000000..397f35e76 --- /dev/null +++ b/packages/backend/src/utils/__mocks__/webdriver.ts @@ -0,0 +1,6 @@ +/** + * A mock of the seleniumIsReady function. Always resolves. + * + * @returns {Promise} A promise that always resolves. + */ +export const seleniumIsReady = (): Promise => Promise.resolve(); diff --git a/packages/backend/src/utils/cron.ts b/packages/backend/src/utils/cron.ts index f60133eb9..a4861dc16 100644 --- a/packages/backend/src/utils/cron.ts +++ b/packages/backend/src/utils/cron.ts @@ -123,7 +123,7 @@ export default (bypassAuthenticationForInternalRequestsToken: string, autoFetchS chalk.bgGrey.hex("#339933")("") + chalk.whiteBright.bgGrey(` Auto Fetch activated `) + chalk.grey("") + - chalk.green(" This instance will periodically fetch information from data providers for all known stocks."), + chalk.green(" This process will periodically fetch information from data providers for all known stocks."), ); logger.info(""); }; diff --git a/packages/backend/src/utils/webdriver.ts b/packages/backend/src/utils/webdriver.ts index 91f3b16c6..9b43f8d3b 100644 --- a/packages/backend/src/utils/webdriver.ts +++ b/packages/backend/src/utils/webdriver.ts @@ -6,13 +6,25 @@ import logger, { PREFIX_SELENIUM } from "./logger.js"; import chalk from "chalk"; import { Stock, resourceEndpointPath } from "@rating-tracker/commons"; import { createResource } from "../redis/repositories/resourceRepository.js"; -import axios from "axios"; +import axios, { AxiosError } from "axios"; /** * A page load strategy to use by the WebDriver. */ type PageLoadStrategy = "normal" | "eager" | "none"; +/** + * Checks if the Selenium instance is reachable. + * + * @returns {Promise} A promise that resolves when the Selenium instance is reachable, or rejects with an error if + * it is not. + */ +export const seleniumIsReady = (): Promise => + axios + .get(`${process.env.SELENIUM_URL}/status`, { timeout: 1000 }) + .then((res) => (res.data.value.ready ? Promise.resolve() : Promise.reject(new Error("Selenium is not ready")))) + .catch((e: AxiosError) => Promise.reject(new Error("Selenium is not reachable: " + e.message))); + /** * Creates and returns a new WebDriver instance. * diff --git a/packages/backend/test/live.test.ts b/packages/backend/test/live.test.ts index 919bdf66f..c3d7ed4ac 100644 --- a/packages/backend/test/live.test.ts +++ b/packages/backend/test/live.test.ts @@ -13,6 +13,7 @@ import { sentMessages } from "../src/signal/__mocks__/signalBase"; import * as signal from "../src/signal/signal"; vi.mock("../src/utils/logger"); +vi.mock("../src/utils/webdriver"); vi.mock("../src/signal/signalBase"); vi.mock("@simplewebauthn/server", async () => await import("./moduleMocks/@simplewebauthn/server"));