import Docker from 'dockerode' import { hostname } from 'node:os' import { PassThrough, type Readable, type Writable } from 'node:stream' import vpsConfig from '#config/vps' import UserError from '#exceptions/user_error' import { lineReader } from '#services/job_runner' export interface ContainerStats { /** * Share of the whole host CPU (0-100). */ cpu: number memUsed: number memLimit: number ts: number } export interface ContainerSummary { id: string name: string image: string state: string status: string health: string | null createdAt: number composeProject: string | null composeWorkingDir: string | null composeService: string | null networks: string[] publishedPorts: string[] stats: ContainerStats | null self: boolean } export type ContainerAction = 'start' | 'stop' | 'restart' export interface LogLine { ts: string | null stream: 'out' | 'err' text: string } interface CpuSample { container: number system: number } class DockerService { readonly docker = new Docker( vpsConfig.docker.socketPath ? { socketPath: vpsConfig.docker.socketPath } : undefined ) #stats = new Map() #previousCpu = new Map() #statsTimer: NodeJS.Timeout | null = null #selfId: Promise | null = null /** * Id of the container running the dashboard (null in development). * Inside a container, the hostname is the short container id. */ selfId(): Promise { this.#selfId ??= this.docker .getContainer(hostname()) .inspect() .then((info) => info.Id) .catch(() => null) return this.#selfId } async isSelf(id: string) { const selfId = await this.selfId() return selfId !== null && (selfId === id || selfId.startsWith(id)) } async list(): Promise { const [containers, selfId] = await Promise.all([ this.docker.listContainers({ all: true }), this.selfId(), ]) return containers .map((container) => { const labels = container.Labels ?? {} const health = /\((healthy|unhealthy|health: starting)\)/.exec(container.Status)?.[1] return { id: container.Id, name: (container.Names[0] ?? container.Id).replace(/^\//, ''), image: container.Image, state: container.State, status: container.Status, health: health ? health.replace('health: ', '') : null, createdAt: container.Created, composeProject: labels['com.docker.compose.project'] ?? null, composeWorkingDir: labels['com.docker.compose.project.working_dir'] ?? null, composeService: labels['com.docker.compose.service'] ?? null, networks: Object.keys(container.NetworkSettings?.Networks ?? {}), publishedPorts: [ ...new Set( (container.Ports ?? []) .filter((port) => port.PublicPort) .map((port) => formatPort(port.IP, port.PublicPort, `${port.PrivatePort}/${port.Type}`) ) ), ], stats: container.State === 'running' ? (this.#stats.get(container.Id) ?? null) : null, self: selfId === container.Id, } }) .sort((a, b) => a.name.localeCompare(b.name)) } /** * Details shown on the container page. The environment variables are * left out on purpose: they hold passwords. */ async inspect(id: string) { const info = await this.docker.getContainer(id).inspect() const labels = info.Config.Labels ?? {} return { id: info.Id, name: info.Name.replace(/^\//, ''), image: info.Config.Image, state: info.State.Status, health: info.State.Health?.Status ?? null, startedAt: info.State.StartedAt, finishedAt: info.State.FinishedAt, exitCode: info.State.ExitCode, restartCount: info.RestartCount, restartPolicy: info.HostConfig.RestartPolicy?.Name ?? null, createdAt: info.Created, command: [...(info.Config.Entrypoint ?? []), ...(info.Config.Cmd ?? [])].join(' '), workingDir: info.Config.WorkingDir, composeProject: labels['com.docker.compose.project'] ?? null, composeWorkingDir: labels['com.docker.compose.project.working_dir'] ?? null, composeService: labels['com.docker.compose.service'] ?? null, networks: Object.keys(info.NetworkSettings.Networks ?? {}), mounts: (info.Mounts ?? []).map((mount) => ({ type: mount.Type, source: mount.Name ?? mount.Source, destination: mount.Destination, readOnly: !mount.RW, })), publishedPorts: [ ...new Set( Object.entries(info.NetworkSettings.Ports ?? {}).flatMap(([containerPort, bindings]) => (bindings ?? []).map((binding) => formatPort(binding.HostIp, binding.HostPort, containerPort) ) ) ), ], stats: this.#stats.get(info.Id) ?? null, self: await this.isSelf(info.Id), } } async action(id: string, action: ContainerAction) { if (action === 'stop' && (await this.isSelf(id))) { throw new UserError('Impossible d’arrêter le conteneur du tableau de bord depuis lui-même.') } const container = this.docker.getContainer(id) try { if (action === 'start') await container.start() else if (action === 'stop') await container.stop() else await container.restart() } catch (error: any) { /** * 304: the container is already in the requested state. */ if (error?.statusCode !== 304) throw error } } /** * Follows the logs of a container. Returns a function stopping the stream. */ async followLogs( id: string, options: { tail: number }, onLine: (line: LogLine) => void, onEnd: () => void ): Promise<() => void> { const container = this.docker.getContainer(id) const info = await container.inspect() const stream = (await container.logs({ follow: true, stdout: true, stderr: true, tail: options.tail, timestamps: true, })) as unknown as Readable const parse = (source: LogLine['stream']) => (raw: string) => { const space = raw.indexOf(' ') const hasTs = space > 0 && /^\d{4}-\d{2}-\d{2}T/.test(raw) onLine({ ts: hasTs ? raw.slice(0, space) : null, stream: source, text: hasTs ? raw.slice(space + 1) : raw, }) } /** * Without a TTY, Docker multiplexes stdout and stderr in one stream. */ if (info.Config.Tty) { lineReader(stream, parse('out')) } else { const stdout = new PassThrough() const stderr = new PassThrough() this.docker.modem.demuxStream(stream, stdout, stderr) stream.on('end', () => { stdout.end() stderr.end() }) lineReader(stdout, parse('out')) lineReader(stderr, parse('err')) } let ended = false const end = () => { if (ended) return ended = true onEnd() } stream.on('end', end) stream.on('error', end) return () => { stream.destroy() } } /** * Runs a command inside a container and waits for it. */ async exec(containerName: string, cmd: string[], stdoutTarget?: Writable) { const container = this.docker.getContainer(containerName) const exec = await container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }) const stream = await exec.start({ hijack: true, stdin: false }) const stdoutChunks: Buffer[] = [] const stderrChunks: Buffer[] = [] const stdout = stdoutTarget ?? collect(stdoutChunks) const stderr = collect(stderrChunks) this.docker.modem.demuxStream(stream, stdout, stderr) await new Promise((resolve, reject) => { stream.on('end', resolve) stream.on('close', resolve) stream.on('error', reject) }) const result = await exec.inspect() return { exitCode: result.ExitCode ?? -1, stdout: Buffer.concat(stdoutChunks).toString('utf8'), stderr: Buffer.concat(stderrChunks).toString('utf8'), } } /** * Collects the CPU and memory usage of the running containers. Uses * "one-shot" stats (instant) and computes the CPU usage between two polls. */ async refreshStats() { const running = await this.docker.listContainers() const seen = new Set() await Promise.all( running.map(async ({ Id: id }) => { seen.add(id) try { const stats: any = await this.docker .getContainer(id) .stats({ 'stream': false, 'one-shot': true } as any) const sample: CpuSample = { container: Number(stats.cpu_stats?.cpu_usage?.total_usage ?? 0), system: Number(stats.cpu_stats?.system_cpu_usage ?? 0), } const previous = this.#previousCpu.get(id) this.#previousCpu.set(id, sample) let cpu = 0 if (previous && sample.system > previous.system) { cpu = ((sample.container - previous.container) / (sample.system - previous.system)) * 100 } const memory = stats.memory_stats ?? {} const cache = memory.stats?.inactive_file ?? memory.stats?.total_inactive_file ?? memory.stats?.cache ?? 0 this.#stats.set(id, { cpu: Math.max(0, Math.min(100, cpu)), memUsed: Math.max(0, Number(memory.usage ?? 0) - Number(cache)), memLimit: Number(memory.limit ?? 0), ts: Date.now(), }) } catch { // The container may have stopped between the list and the stats call. } }) ) for (const id of this.#stats.keys()) { if (!seen.has(id)) { this.#stats.delete(id) this.#previousCpu.delete(id) } } } startStatsPolling(intervalMs = 10_000) { const tick = () => this.refreshStats().catch(() => {}) tick() this.#statsTimer = setInterval(tick, intervalMs) } stopStatsPolling() { if (this.#statsTimer) clearInterval(this.#statsTimer) } } /** * "8080->80/tcp" for a port open on every interface (reachable from * Internet), "127.0.0.1:8080->80/tcp" when bound to one address only. */ function formatPort(ip: string | undefined, hostPort: number | string, target: string) { const everywhere = !ip || ip === '0.0.0.0' || ip === '::' return `${everywhere ? '' : `${ip}:`}${hostPort}->${target}` } function collect(chunks: Buffer[]) { const stream = new PassThrough() stream.on('data', (chunk: Buffer) => chunks.push(chunk)) return stream } /** * True when the error comes from the Docker socket being unreachable. */ export function isDockerUnreachable(error: unknown) { const code = (error as NodeJS.ErrnoException)?.code return ( code === 'ECONNREFUSED' || code === 'EACCES' || (code === 'ENOENT' && (error as any)?.syscall === 'connect') ) } const dockerService = new DockerService() export default dockerService