add docker logs to provider
This commit is contained in:
1 parent
efcc7829d4
commit
3e06cd39b1
1 file changed
+85
-2
@@ -2,7 +2,9 @@ import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
||||
import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
|
||||
import {
|
||||
DOCKER_LOG_TAIL_LINES,
|
||||
DockerContainerInfo,
|
||||
DockerContainerLogs,
|
||||
DockerContainerMount,
|
||||
DockerContainerPort,
|
||||
DockerContainerState,
|
||||
@@ -10,6 +12,8 @@ import {
|
||||
DockerContainerSummary,
|
||||
DockerHealthStatus,
|
||||
DockerHealthStatuses,
|
||||
DockerLogLine,
|
||||
DockerLogStream,
|
||||
DockerPortProtocol,
|
||||
DockerPortProtocols,
|
||||
} from "@/domain/docker/docker.type.js";
|
||||
@@ -23,6 +27,9 @@ type DockerodeType = typeof Dockerode;
|
||||
|
||||
const SERVICE_NAME = "DockerProvider";
|
||||
const STATS_CONCURRENCY = 5;
|
||||
const DOCKER_LOG_MAX_LINE_BYTES = 4096;
|
||||
const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]";
|
||||
const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /;
|
||||
|
||||
export interface DockerError extends Error {
|
||||
statusCode?: number;
|
||||
@@ -119,10 +126,14 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
private toContainerName = (summary: Dockerode.ContainerInfo): string => {
|
||||
return summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12);
|
||||
};
|
||||
|
||||
private async toContainerInfo(docker: Dockerode, summary: Dockerode.ContainerInfo): Promise<DockerContainerInfo> {
|
||||
const info: DockerContainerInfo = {
|
||||
id: summary.Id,
|
||||
name: summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12),
|
||||
name: this.toContainerName(summary),
|
||||
image: summary.Image,
|
||||
state: this.toContainerState(summary.State),
|
||||
status: summary.Status,
|
||||
@@ -198,6 +209,76 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
}));
|
||||
}
|
||||
|
||||
// Log parsing
|
||||
private isMultiplexed(buffer: Buffer): boolean {
|
||||
return buffer.length >= 8 && buffer.readUInt8(0) <= 2 && buffer.readUInt8(1) === 0 && buffer.readUInt8(2) === 0 && buffer.readUInt8(3) === 0;
|
||||
}
|
||||
|
||||
// Docker will will merge stdout and stderr if stared with `-t`.
|
||||
// Most containers will _not_ be run with `-t` though, in which case logs come in frames of [header][payload]
|
||||
private demuxLogBuffer = (buffer: Buffer): { stream: DockerLogStream; chunk: string }[] => {
|
||||
if (!this.isMultiplexed(buffer)) return [{ stream: "stdout", chunk: buffer.toString("utf8") }]; // `-t` case, log is just a string, return it
|
||||
const frames: { stream: DockerLogStream; chunk: string }[] = [];
|
||||
let offset = 0; // Start reading at the first byte of the first header
|
||||
while (offset + 8 <= buffer.length) {
|
||||
// Every 8 bytes is a header, if < 8 bytes at the end, invalid frame, stop
|
||||
const streamType = buffer.readUint8(offset); // First byte of the header is the stream type: 0 = stdin, 1 = stdout, 2 = stderr
|
||||
const payloadSize = buffer.readUint32BE(offset + 4); // Byte 4 to 7 describe how many payload bytes follow the header
|
||||
const payloadStart = offset + 8; // Advance to the payload (header 8 bytes long)
|
||||
const payloadEnd = Math.min(payloadStart + payloadSize, buffer.length); // End is the start of the payload + payload size.
|
||||
frames.push({ stream: streamType === 2 ? "stderr" : "stdout", chunk: buffer.toString("utf-8", payloadStart, payloadEnd) });
|
||||
offset = payloadEnd;
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
|
||||
private truncateLog = (text: string): string => {
|
||||
if (Buffer.byteLength(text, "utf8") <= DOCKER_LOG_MAX_LINE_BYTES) return text;
|
||||
const cut = Buffer.from(text, "utf8").subarray(0, DOCKER_LOG_MAX_LINE_BYTES).toString("utf8").replace(/�$/, "");
|
||||
return cut + DOCKER_LOG_TRUNCATION_MARKER;
|
||||
};
|
||||
|
||||
// Docker drops trailing zeroes form timestamps. Pad out timestamps for correct string comparison
|
||||
private toLogLine(stream: DockerLogStream, raw: string): DockerLogLine | null {
|
||||
const match = raw.match(DOCKER_LOG_TS_REGEX);
|
||||
if (!match) return null; // If there's no time stamp, something is wrong with this line, drop it
|
||||
const ts = `${match[1]}.${(match[2] ?? "").padEnd(9, "0")}Z`;
|
||||
const text = raw.slice(match[0].length).replace(/\r$/, "");
|
||||
return { ts, stream, text: this.truncateLog(text) };
|
||||
}
|
||||
|
||||
private parseLogBuffer = (buffer: Buffer): DockerLogLine[] => {
|
||||
const lines: DockerLogLine[] = [];
|
||||
const demuxedBuffer = this.demuxLogBuffer(buffer);
|
||||
for (const byteStream of demuxedBuffer) {
|
||||
const { stream, chunk } = byteStream;
|
||||
for (const raw of chunk.split("/n")) {
|
||||
if (raw.length === 0) continue;
|
||||
const line = this.toLogLine(stream, raw);
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
private toContainerLogs = async (docker: Dockerode, summary: Dockerode.ContainerInfo): Promise<DockerContainerLogs | null> => {
|
||||
const containerName = this.toContainerName(summary);
|
||||
try {
|
||||
const buffer = await docker
|
||||
.getContainer(summary.Id)
|
||||
.logs({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES });
|
||||
return { containerId: summary.Id, containerName, lines: this.parseLogBuffer(buffer) };
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: `Failed to read logs for container ${containerName}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "toContainerLogs",
|
||||
details: { containerId: summary.Id, error: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
handle = async (monitor: Monitor): Promise<MonitorStatusResponse<DockerStatusPayload>> => {
|
||||
try {
|
||||
const docker = new this.DockerLib(this.toDockerOptions(monitor));
|
||||
@@ -226,6 +307,8 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
|
||||
const summaries = await docker.listContainers({ all: true });
|
||||
const containers = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerInfo(docker, s));
|
||||
const rawLogs = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerLogs(docker, s));
|
||||
const logs = rawLogs.filter((log) => log !== null);
|
||||
const summary: DockerContainerSummary = {
|
||||
total: containers.length,
|
||||
running: containers.filter((c) => c.state === "running").length,
|
||||
@@ -240,7 +323,7 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
code: 200,
|
||||
message: "Docker host is reachable",
|
||||
responseTime,
|
||||
payload: { containers, summary },
|
||||
payload: { containers, summary, logs },
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AppError) throw error;
|
||||
|
||||
Reference in new issue
Block a user