first commit
This commit is contained in:
commit
3c0479faf0
169 files changed
+27617
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user