first commit

This commit is contained in:
Rutra committed 2026-10-12 14:25:22 +02:00
commit 3c0479faf0
169 files changed
+27617

No files matched your search

+360
View File
@@ -0,0 +1,360 @@
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