Files
big-brother/api/app/services/docker_service.ts
T
2026-10-12 14:25:22 +02:00

361 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, ContainerStats>()
#previousCpu = new Map<string, CpuSample>()
#statsTimer: NodeJS.Timeout | null = null
#selfId: Promise<string | null> | null = null
/**
* Id of the container running the dashboard (null in development).
* Inside a container, the hostname is the short container id.
*/
selfId(): Promise<string | null> {
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<ContainerSummary[]> {
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<void>((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<string>()
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