diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 953d89b0f..bd13b7dfc 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -29,7 +29,6 @@ import { NotificationMessageBuilder, INotificationMessageBuilder } from "../serv import axios from "axios"; import got from "got"; import ping from "ping"; -import https from "https"; import Docker from "dockerode"; import net from "net"; import fs from "fs"; @@ -83,6 +82,7 @@ import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvi import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.js"; import { PortProvider } from "@/service/infrastructure/network/PortProvider.js"; import { GameProvider } from "@/service/infrastructure/network/GameProvider.js"; +import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js"; export type InitializedServices = { settingsService: any; @@ -161,29 +161,19 @@ export const initializeServices = async ({ const dockerProvider = new DockerProvider(logger, Docker); const portProvider = new PortProvider(net); const gameProvider = new GameProvider(logger, GameDig); + const grpcProvider = new GrpcProvider(grpc, protoLoader); const networkService = new NetworkService( axios, - got, - https, - jmespath, - GameDig as unknown as { - query: (options: { type: string; host: string; port?: number }) => Promise<{ ping?: number } & { [key: string]: unknown }>; - }, - ping, logger, - Docker, - net, - settingsService, - grpc, - protoLoader, pingProvider, httpProvider, pageSpeedProvider, hardwareProvider, dockerProvider, portProvider, - gameProvider + gameProvider, + grpcProvider ); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/src/service/infrastructure/network/GrpcProvider.ts b/server/src/service/infrastructure/network/GrpcProvider.ts index 4bf127785..66684ba0c 100644 --- a/server/src/service/infrastructure/network/GrpcProvider.ts +++ b/server/src/service/infrastructure/network/GrpcProvider.ts @@ -2,29 +2,21 @@ import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvide import { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { Monitor, MonitorType } from "@/types/monitor.js"; import { AppError } from "@/utils/AppError.js"; -import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; -import { ILogger } from "@/utils/logger.js"; +import { timeRequest } from "@/service/infrastructure/network/utils.js"; import * as grpc from "@grpc/grpc-js"; import * as protoLoader from "@grpc/proto-loader"; import path from "path"; import { fileURLToPath } from "url"; -import { Network } from "inspector/promises"; type GrpcType = typeof grpc; type ProtoLoaderType = typeof protoLoader; const SERVICE_NAME = "GrpcProvider"; -export interface GrpcError extends Error { - code: number; - details?: string; -} - export class GrpcProvider implements IStatusProvider { readonly type = "grpc"; constructor( - private logger: ILogger, private grpc: GrpcType, private protoLoader: ProtoLoaderType ) {} @@ -33,10 +25,6 @@ export class GrpcProvider implements IStatusProvider { return type === "grpc"; } - private isGrpcError(error: unknown): error is GrpcError { - return error instanceof Error && "code" in error && typeof (error as Record).code === "number"; - } - private getGrpcStatusName(code: number): string { const statusNames: Record = { 0: "OK", @@ -61,15 +49,20 @@ export class GrpcProvider implements IStatusProvider { } async handle(monitor: Monitor): Promise> { - const { id, teamId, type, url, port, ignoreTlsErrors } = monitor; - const grpcServiceName = monitor.grpcServiceName || ""; - const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; - try { - if (!host || !port) { - throw new Error("Valid host and port are required"); + const { id, teamId, type, url, port, ignoreTlsErrors } = monitor; + const grpcServiceName = monitor.grpcServiceName || ""; + + if (!url) { + throw new AppError({ message: "Monitor host is required", service: SERVICE_NAME, method: "handle" }); } + if (!port) { + throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" }); + } + + const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; const target = `${host}:${port}`; + const currentFilePath = fileURLToPath(import.meta.url); const protoPath = path.join(path.dirname(currentFilePath), "protos", "health.proto"); const packageDefinition = this.protoLoader.loadSync(protoPath, { @@ -79,8 +72,23 @@ export class GrpcProvider implements IStatusProvider { defaults: true, oneofs: true, }); - const grpcObject = this.grpc.loadPackageDefinition(packageDefinition) as any; - const HealthClient = grpcObject.grpc.health.v1.Health; + const grpcObject = this.grpc.loadPackageDefinition(packageDefinition) as unknown as { + grpc: { + health: { + v1: { + Health: new ( + target: string, + credentials: unknown + ) => { + Check: (request: { service: string }, options: { deadline: Date }, callback: (err: unknown, response: unknown) => void) => void; + close: () => void; + }; + }; + }; + }; + }; + const healthService = grpcObject.grpc.health.v1.Health; + let credentials; if (ignoreTlsErrors) { credentials = this.grpc.credentials.createSsl(null, null, null, { @@ -89,8 +97,12 @@ export class GrpcProvider implements IStatusProvider { } else { credentials = this.grpc.credentials.createInsecure(); } - const client = new HealthClient(target, credentials); - const deadline = new Date(Date.now() + 10000); // 10s timeout + + const client = new healthService(target, credentials); + + const TIMEOUT_MS = 10000; + const deadline = new Date(Date.now() + TIMEOUT_MS); + const { response, responseTime, error } = await timeRequest(() => { return new Promise((resolve, reject) => { client.Check({ service: grpcServiceName }, { deadline }, (err: unknown, response: unknown) => { @@ -128,51 +140,32 @@ export class GrpcProvider implements IStatusProvider { }); if (error) { - let grpcCode = -1; - let message = error instanceof Error ? error.message : "gRPC health check failed"; - - if (this.isGrpcError(error)) { - grpcCode = error.code; - message = error.details || error.message; - } - + const grpcError = error as AppError & { grpcPayload?: GrpcStatusPayload; grpcCode?: number }; + const payload = grpcError.grpcPayload; return { monitorId: id, teamId: teamId, type: type, status: false, - code: 500, - message: message, + code: grpcError.grpcCode ?? 5000, + message: grpcError.message ?? "gRPC health check failed", responseTime, - payload: { - grpcStatusCode: grpcCode, - grpcStatusName: this.getGrpcStatusName(grpcCode), - serviceName: grpcServiceName, - servingStatus: "UNKNOWN", - }, + payload: payload ?? null, }; } - if (!response) { - throw new Error("No response received from gRPC health check"); - } - - const isServing = response.servingStatus === "SERVING"; + const grpcPayload = response as GrpcStatusPayload; + const isServing = grpcPayload.servingStatus === "SERVING"; return { monitorId: id, teamId: teamId, type: type, status: isServing, - code: isServing ? 200 : NETWORK_ERROR, - message: isServing ? "gRPC health check successful" : "gRPC health check failed", + code: isServing ? 200 : 5000, + message: isServing ? `gRPC service healthy (${grpcPayload.servingStatus})` : `gRPC service unhealthy (${grpcPayload.servingStatus})`, responseTime, - payload: { - grpcStatusCode: isServing ? 200 : NETWORK_ERROR, - grpcStatusName: response.grpcStatusName, - serviceName: response.serviceName, - servingStatus: response.servingStatus || "UNKNOWN", - }, + payload: grpcPayload, }; } catch (err: unknown) { const originalMessage = err instanceof Error ? err.message : String(err); diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index e373e2cff..42fa39a40 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -6,6 +6,7 @@ import { MonitorStatusResponse } from "@/types/network.js"; import { Agent as HttpsAgent } from "https"; import { Monitor, MonitorType } from "@/types/monitor.js"; import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; +import CacheableLookup from "cacheable-lookup"; export class HttpProvider implements IStatusProvider { readonly type = "http"; @@ -13,7 +14,16 @@ export class HttpProvider implements IStatusProvider { constructor( private got: Got, private advancedMatcher: AdvancedMatcher - ) {} + ) { + const cacheable = new CacheableLookup(); + this.got = got.extend({ + dnsCache: cacheable, + timeout: { + request: 30000, + }, + retry: { limit: 1 }, + }); + } supports(type: MonitorType) { return type === "http"; diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 1a0d71553..7b7c739d8 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -1,5 +1,4 @@ -import { HTTPError, RequestError } from "got"; -import type { Got, Response } from "got"; +import type { Got } from "got"; import type { Monitor, MonitorStatusResponse, @@ -14,28 +13,12 @@ import type { } from "@/types/index.js"; import type { AxiosStatic } from "axios"; import { AppError } from "@/utils/AppError.js"; -import path from "path"; -import { fileURLToPath } from "url"; -import { MonitorStatusResponseOverrides } from "@/types/index.js"; -import CacheableLookup from "cacheable-lookup"; import { ISettingsService } from "../system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; import { IStatusProvider } from "./network/IStatusProvider.js"; const SERVICE_NAME = "NetworkService"; -interface BuildStatusResponseArgs { - monitor: Monitor; - response?: Response | null; - error?: Error | RequestError | HTTPError | null; - payload?: T | null; - jsonPath?: string; - matchMethod?: MonitorStatusResponse["matchMethod"]; - expectedValue?: string; - extracted?: unknown; - overrides?: MonitorStatusResponseOverrides; -} - export interface INetworkService { readonly serviceName: string; requestStatus( @@ -82,19 +65,7 @@ class NetworkService implements INetworkService { private PING_ERROR: number; private axios: AxiosStatic; - private got: Got; - private https: typeof import("https"); - private jmespath: { search: (data: unknown, expression: string) => unknown }; - private GameDig: { query: (options: { type: string; host: string; port?: number }) => Promise<{ ping?: number } & { [key: string]: unknown }> }; - private ping: typeof import("ping"); private logger: ILogger; - private Docker: typeof import("dockerode"); - private net: typeof import("net"); - private settingsService: ISettingsService; - private grpc: typeof import("@grpc/grpc-js"); - private protoLoader: typeof import("@grpc/proto-loader"); - - // New providers private pingProvider; private httpProvider; private pageSpeedProvider; @@ -102,83 +73,19 @@ class NetworkService implements INetworkService { private dockerProvider; private portProvider; private gameProvider; - - private buildStatusResponse = ({ - monitor, - response, - error, - payload, - jsonPath, - matchMethod, - expectedValue, - extracted, - overrides, - }: BuildStatusResponseArgs): MonitorStatusResponse => { - if (error) { - const statusResponse: MonitorStatusResponse = { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: false, - code: this.NETWORK_ERROR, - message: error.message ?? "Network error", - responseTime: 0, - timings: undefined, - jsonPath, - matchMethod, - expectedValue, - extracted, - payload, - }; - if (error instanceof HTTPError || error instanceof RequestError) { - statusResponse.code = error?.response?.statusCode ?? this.NETWORK_ERROR; - statusResponse.message = error.message; - statusResponse.responseTime = error.timings?.phases?.total ?? 0; - statusResponse.timings = error.timings; - } - return { ...statusResponse, ...(overrides ?? {}) } as MonitorStatusResponse; - } - - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: response?.ok ?? false, - code: response?.statusCode ?? this.NETWORK_ERROR, - message: response?.statusMessage ?? "", - responseTime: response?.timings?.phases?.total ?? 0, - timings: response?.timings, - payload: payload ?? response?.body, - jsonPath, - matchMethod, - expectedValue, - extracted, - ...(overrides ?? {}), - } as MonitorStatusResponse; - }; + private grpcProvider; constructor( axios: AxiosStatic, - got: Got, - https: typeof import("https"), - jmespath: { search: (data: unknown, expression: string) => unknown }, - GameDig: { query: (options: { type: string; host: string; port?: number }) => Promise<{ ping?: number } & { [key: string]: unknown }> }, - ping: typeof import("ping"), logger: ILogger, - Docker: typeof import("dockerode"), - net: typeof import("net"), - settingsService: ISettingsService, - grpc: typeof import("@grpc/grpc-js"), - protoLoader: typeof import("@grpc/proto-loader"), - - // New providers pingProvider: IStatusProvider, httpProvider: IStatusProvider, pagespeedProvider: IStatusProvider, hardwareProvider: IStatusProvider, dockerProvider: IStatusProvider, portProvider: IStatusProvider, - gameProvider: IStatusProvider + gameProvider: IStatusProvider, + grpcProvider: IStatusProvider ) { this.TYPE_PING = "ping"; this.TYPE_HTTP = "http"; @@ -192,18 +99,8 @@ class NetworkService implements INetworkService { this.NETWORK_ERROR = 5000; this.PING_ERROR = 5001; this.axios = axios; - this.https = https; - this.jmespath = jmespath; - this.GameDig = GameDig; - this.ping = ping; this.logger = logger; - this.Docker = Docker; - this.net = net; - this.settingsService = settingsService; - this.grpc = grpc; - this.protoLoader = protoLoader; - // New providers this.pingProvider = pingProvider; this.httpProvider = httpProvider; this.pageSpeedProvider = pagespeedProvider; @@ -211,34 +108,13 @@ class NetworkService implements INetworkService { this.dockerProvider = dockerProvider; this.portProvider = portProvider; this.gameProvider = gameProvider; - const cacheable = new CacheableLookup(); - - this.got = got.extend({ - dnsCache: cacheable, - timeout: { - request: 30000, - }, - retry: { limit: 1 }, - }); + this.grpcProvider = grpcProvider; } get serviceName(): string { return NetworkService.SERVICE_NAME; } - // Helper functions - private async timeRequest(operation: () => Promise): Promise<{ response: T | null; responseTime: number; error: unknown }> { - const start = process.hrtime.bigint(); - try { - const response = await operation(); - const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000); - return { response, responseTime: elapsedMs, error: null }; - } catch (error) { - const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000); - return { response: null, responseTime: elapsedMs, error }; - } - } - // Main entry point async requestStatus( monitor: Monitor @@ -271,169 +147,12 @@ class NetworkService implements INetworkService { case this.TYPE_GAME: return await this.gameProvider.handle(monitor); case this.TYPE_GRPC: - return await this.requestGrpc(monitor); + return await this.grpcProvider.handle(monitor); default: return this.handleUnsupportedType(type); } } - private async requestGrpc(monitor: Monitor): Promise> { - try { - const { url, port, ignoreTlsErrors } = monitor; - const grpcServiceName = monitor.grpcServiceName || ""; - - if (!url) { - throw new AppError({ message: "Monitor host is required", service: this.SERVICE_NAME, method: "requestGrpc" }); - } - if (!port) { - throw new AppError({ message: "Monitor port is required", service: this.SERVICE_NAME, method: "requestGrpc" }); - } - - const target = `${url}:${port}`; - - const currentFilePath = fileURLToPath(import.meta.url); - const protoPath = path.join(path.dirname(currentFilePath), "protos", "health.proto"); - - const packageDefinition = this.protoLoader.loadSync(protoPath, { - keepCase: true, - longs: String, - enums: String, - defaults: true, - oneofs: true, - }); - const grpcObject = this.grpc.loadPackageDefinition(packageDefinition) as unknown as { - grpc: { - health: { - v1: { - Health: new ( - target: string, - credentials: unknown - ) => { - Check: (request: { service: string }, options: { deadline: Date }, callback: (err: unknown, response: unknown) => void) => void; - close: () => void; - }; - }; - }; - }; - }; - const healthService = grpcObject.grpc.health.v1.Health; - - let credentials; - if (ignoreTlsErrors) { - credentials = this.grpc.credentials.createSsl(null, null, null, { - checkServerIdentity: () => undefined, - }); - } else { - credentials = this.grpc.credentials.createInsecure(); - } - - const client = new healthService(target, credentials); - - const TIMEOUT_MS = 10000; - const deadline = new Date(Date.now() + TIMEOUT_MS); - - const grpcResponse = this.buildStatusResponse({ - monitor, - overrides: { - status: false, - code: this.NETWORK_ERROR, - message: "gRPC health check not executed", - }, - }); - - const { response, responseTime, error } = await this.timeRequest(() => { - return new Promise((resolve, reject) => { - client.Check({ service: grpcServiceName }, { deadline }, (err: unknown, response: unknown) => { - client.close(); - - if (err) { - const grpcErr = err as { code?: number; details?: string; message?: string }; - const payload: GrpcStatusPayload = { - grpcStatusCode: grpcErr.code ?? -1, - grpcStatusName: this.getGrpcStatusName(grpcErr.code ?? -1), - serviceName: grpcServiceName, - servingStatus: "UNKNOWN", - }; - const grpcError = new AppError({ - message: grpcErr.details || grpcErr.message || "gRPC error", - service: this.SERVICE_NAME, - method: "requestGrpc", - }) as AppError & { grpcPayload?: GrpcStatusPayload; grpcCode?: number }; - grpcError.grpcPayload = payload; - grpcError.grpcCode = grpcErr.code; - reject(grpcError); - return; - } - - const resp = response as { status?: string } | undefined; - const servingStatus = resp?.status ?? "UNKNOWN"; - resolve({ - grpcStatusCode: 0, - grpcStatusName: "OK", - serviceName: grpcServiceName, - servingStatus, - }); - }); - }); - }); - - if (error) { - const grpcError = error as AppError & { grpcPayload?: GrpcStatusPayload; grpcCode?: number }; - const payload = grpcError.grpcPayload; - grpcResponse.status = false; - grpcResponse.code = grpcError.grpcCode ?? this.NETWORK_ERROR; - grpcResponse.message = grpcError.message ?? "gRPC health check failed"; - grpcResponse.responseTime = responseTime; - grpcResponse.payload = payload ?? null; - return grpcResponse; - } - - const grpcPayload = response as GrpcStatusPayload; - const isServing = grpcPayload.servingStatus === "SERVING"; - - grpcResponse.status = isServing; - grpcResponse.code = isServing ? 200 : this.NETWORK_ERROR; - grpcResponse.message = isServing - ? `gRPC service healthy (${grpcPayload.servingStatus})` - : `gRPC service unhealthy (${grpcPayload.servingStatus})`; - grpcResponse.responseTime = responseTime; - grpcResponse.payload = grpcPayload; - - return grpcResponse; - } catch (err: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing gRPC health check", - service: this.SERVICE_NAME, - method: "requestGrpc", - details: { url: monitor.url, port: monitor.port, grpcServiceName: monitor.grpcServiceName }, - }); - } - } - - private getGrpcStatusName(code: number): string { - const statusNames: Record = { - 0: "OK", - 1: "CANCELLED", - 2: "UNKNOWN", - 3: "INVALID_ARGUMENT", - 4: "DEADLINE_EXCEEDED", - 5: "NOT_FOUND", - 6: "ALREADY_EXISTS", - 7: "PERMISSION_DENIED", - 8: "RESOURCE_EXHAUSTED", - 9: "FAILED_PRECONDITION", - 10: "ABORTED", - 11: "OUT_OF_RANGE", - 12: "UNIMPLEMENTED", - 13: "INTERNAL", - 14: "UNAVAILABLE", - 15: "DATA_LOSS", - 16: "UNAUTHENTICATED", - }; - return statusNames[code] || "UNKNOWN"; - } - private async handleUnsupportedType(type: string): Promise { return { monitorId: "unknown",