From f3b156f2d0c02c19983c65b6eb8b759aa6919a42 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 5 Mar 2026 18:19:03 +0000 Subject: [PATCH] docker provider --- server/src/config/services.ts | 6 +- .../infrastructure/network/DockerProvider.ts | 160 ++++++++++++++++++ .../service/infrastructure/networkService.ts | 9 +- 3 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 server/src/service/infrastructure/network/DockerProvider.ts diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 11e795feb..eed243817 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -80,6 +80,7 @@ import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js"; import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js"; import { PageSpeedProvider } from "@/service/infrastructure/network/PageSpeedProvider.js"; import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvider.js"; +import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.js"; export type InitializedServices = { settingsService: any; @@ -155,6 +156,8 @@ export const initializeServices = async ({ const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath)); const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger); const hardwareProvider = new HardwareProvider(httpProvider); + const dockerProvider = new DockerProvider(logger, Docker); + const networkService = new NetworkService( axios, got, @@ -173,7 +176,8 @@ export const initializeServices = async ({ pingProvider, httpProvider, pageSpeedProvider, - hardwareProvider + hardwareProvider, + dockerProvider ); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/src/service/infrastructure/network/DockerProvider.ts b/server/src/service/infrastructure/network/DockerProvider.ts new file mode 100644 index 000000000..8e84b5dc6 --- /dev/null +++ b/server/src/service/infrastructure/network/DockerProvider.ts @@ -0,0 +1,160 @@ +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { Monitor, MonitorType } from "@/types/monitor.js"; +import { ILogger } from "@/utils/logger.js"; +import { AppError } from "@/utils/AppError.js"; +import Dockerode from "dockerode"; +import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; + +type DockerodeType = typeof Dockerode; + +const SERVICE_NAME = "DockerProvider"; + +export interface DockerError extends Error { + statusCode?: number; + reason?: string; + json?: { message?: string }; +} + +export class DockerProvider implements IStatusProvider { + readonly type = "docker"; + private docker: Dockerode; + constructor( + private logger: ILogger, + private DockerLib: DockerodeType + ) { + this.docker = new this.DockerLib(); + } + + supports(type: MonitorType): boolean { + return type === "docker"; + } + + private isDockerError(error: unknown): error is DockerError { + return error instanceof Error && ("statusCode" in error || "reason" in error || "json" in error); + } + + async handle(monitor: Monitor): Promise> { + const { url: containerInput } = monitor; + try { + if (!containerInput) { + throw new Error("Container name or ID is required for Docker monitor"); + } + + const containers = await this.docker.listContainers({ all: true }); + const normalizedInput = containerInput.replace(/^\/+/, "").toLowerCase(); + + // Priority-based matching to avoid ambiguity: + // 1. Exact full ID match (64-char) + const exactIdMatch = containers.find((c) => c.Id.toLowerCase() === normalizedInput); + + // 2. Exact container name match (case-insensitive) + const exactNameMatch = containers.find((c) => + c.Names.some((name: string) => { + const cleanName = name.replace(/^\/+/, "").toLowerCase(); + return cleanName === normalizedInput; + }) + ); + + // 3. Partial ID match (fallback for backwards compatibility) + const partialIdMatch = containers.find((c) => c.Id.toLowerCase().startsWith(normalizedInput)); + + // Select container based on priority + const targetContainer = exactIdMatch || exactNameMatch || partialIdMatch; + + // Handle no match + if (!targetContainer) { + this.logger.warn({ + message: `No container found for "${monitor.url}".`, + service: SERVICE_NAME, + method: "handle", + details: { url: monitor.url }, + }); + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: 404, + message: "Docker container not found", + responseTime: 0, + payload: null, + }; + } + + // 5. Handle Ambiguity Check + const matchTypes: string[] = []; + if (exactIdMatch) matchTypes.push("exact ID"); + if (exactNameMatch) matchTypes.push("exact name"); + if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID"); + + if (matchTypes.length > 1) { + const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`; + + this.logger.warn({ + message, + service: SERVICE_NAME, + method: "handle", + details: { url: containerInput }, + }); + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: NETWORK_ERROR, + message, + responseTime: 0, + payload: null, + }; + } + + // 6. Inspect Container Status + const container = this.docker.getContainer(targetContainer.Id); + const { response, responseTime, error } = await timeRequest(() => container.inspect()); + + if (error) { + let message = "Failed to fetch Docker container information"; + let code = NETWORK_ERROR; + + if (this.isDockerError(error)) { + code = error.statusCode ?? NETWORK_ERROR; + message = error.json?.message ?? error.reason ?? error.message; + } else if (error instanceof Error) { + message = error.message; + } + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: code, + message: message, + responseTime, + payload: null, + }; + } + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: response?.State?.Status === "running", + code: 200, + message: "Docker container status fetched successfully", + responseTime, + payload: response as unknown as DockerStatusPayload, + }; + } catch (err: unknown) { + throw new AppError({ + message: err instanceof Error ? err.message : "Error performing Docker request", + service: SERVICE_NAME, + method: "handle", + details: { url: containerInput }, + }); + } + } +} diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 539a1f8c2..c79a2cee6 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -99,6 +99,7 @@ class NetworkService implements INetworkService { private httpProvider; private pageSpeedProvider; private hardwareProvider; + private dockerProvider; private buildStatusResponse = ({ monitor, @@ -172,7 +173,8 @@ class NetworkService implements INetworkService { pingProvider: IStatusProvider, httpProvider: IStatusProvider, pagespeedProvider: IStatusProvider, - hardwareProvider: IStatusProvider + hardwareProvider: IStatusProvider, + dockerProvider: IStatusProvider ) { this.TYPE_PING = "ping"; this.TYPE_HTTP = "http"; @@ -202,6 +204,7 @@ class NetworkService implements INetworkService { this.httpProvider = httpProvider; this.pageSpeedProvider = pagespeedProvider; this.hardwareProvider = hardwareProvider; + this.dockerProvider = dockerProvider; const cacheable = new CacheableLookup(); this.got = got.extend({ @@ -254,9 +257,9 @@ class NetworkService implements INetworkService { case this.TYPE_PAGESPEED: return await this.pageSpeedProvider.handle(monitor); case this.TYPE_HARDWARE: - return await this.requestHardware(monitor); + return await this.hardwareProvider.handle(monitor); case this.TYPE_DOCKER: - return await this.requestDocker(monitor); + return await this.dockerProvider.handle(monitor); case this.TYPE_PORT: return await this.requestPort(monitor); case this.TYPE_GAME: