361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
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
|