This commit is contained in:
Alex Holliday committed 2026-03-05 19:34:57 +00:00
1 parent c7689cc64a
commit ad7d4e54c3
4 files changed
+66 -354

No files matched your search

+4 -14
View File
@@ -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);
@@ -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<GrpcStatusPayload> {
readonly type = "grpc";
constructor(
private logger: ILogger,
private grpc: GrpcType,
private protoLoader: ProtoLoaderType
) {}
@@ -33,10 +25,6 @@ export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
return type === "grpc";
}
private isGrpcError(error: unknown): error is GrpcError {
return error instanceof Error && "code" in error && typeof (error as Record<string, unknown>).code === "number";
}
private getGrpcStatusName(code: number): string {
const statusNames: Record<number, string> = {
0: "OK",
@@ -61,15 +49,20 @@ export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<GrpcStatusPayload>> {
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<GrpcStatusPayload> {
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<GrpcStatusPayload> {
} 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<GrpcStatusPayload>(() => {
return new Promise<GrpcStatusPayload>((resolve, reject) => {
client.Check({ service: grpcServiceName }, { deadline }, (err: unknown, response: unknown) => {
@@ -128,51 +140,32 @@ export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
});
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);
@@ -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<HttpStatusPayload> {
readonly type = "http";
@@ -13,7 +14,16 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
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";
@@ -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<T> {
monitor: Monitor;
response?: Response<T> | null;
error?: Error | RequestError | HTTPError | null;
payload?: T | null;
jsonPath?: string;
matchMethod?: MonitorStatusResponse["matchMethod"];
expectedValue?: string;
extracted?: unknown;
overrides?: MonitorStatusResponseOverrides<T>;
}
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 = <T>({
monitor,
response,
error,
payload,
jsonPath,
matchMethod,
expectedValue,
extracted,
overrides,
}: BuildStatusResponseArgs<T>): MonitorStatusResponse<T> => {
if (error) {
const statusResponse: MonitorStatusResponse<T> = {
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<T>;
}
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<T>;
};
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<PingStatusPayload>,
httpProvider: IStatusProvider<HttpStatusPayload>,
pagespeedProvider: IStatusProvider<PageSpeedStatusPayload>,
hardwareProvider: IStatusProvider<HardwareStatusPayload>,
dockerProvider: IStatusProvider<DockerStatusPayload>,
portProvider: IStatusProvider<PortStatusPayload>,
gameProvider: IStatusProvider<GameStatusPayload>
gameProvider: IStatusProvider<GameStatusPayload>,
grpcProvider: IStatusProvider<GrpcStatusPayload>
) {
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<T>(operation: () => Promise<T>): 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<MonitorStatusResponse<GrpcStatusPayload>> {
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<GrpcStatusPayload>({
monitor,
overrides: {
status: false,
code: this.NETWORK_ERROR,
message: "gRPC health check not executed",
},
});
const { response, responseTime, error } = await this.timeRequest<GrpcStatusPayload>(() => {
return new Promise<GrpcStatusPayload>((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<number, string> = {
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<MonitorStatusResponse> {
return {
monitorId: "unknown",