// server/services/dockerApply.ts import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { db, schema } from "../db/client.js"; import { getMachineRow, getCreds } from "./machines.js"; import { renderTemplate } from "../templates/render.js"; import { runScriptSudo } from "../ssh/client.js"; import { outputHub } from "../ws/outputHub.js"; import { cleanDockerError } from "./dockerPull.js"; import type { SnapshotError } from "@shared/types.js"; // ---------------------------------------------------------------------------- // Fonctions pures (testables). // ---------------------------------------------------------------------------- function section(raw: string, start: string, end?: string): string { const i = raw.indexOf(start); if (i < 0) return ""; const from = i + start.length; const j = end ? raw.indexOf(end, from) : -1; return raw.slice(from, j < 0 ? undefined : j); } const ERROR_RE = /\b(error|unauthorized|denied|forbidden|failed|no such host|connection refused|timeout|cannot)\b/i; function collectErrors(text: string, kind: string): SnapshotError[] { const seen = new Set(); const out: SnapshotError[] = []; for (const line of text.split("\n")) { if (!ERROR_RE.test(line)) continue; const message = cleanDockerError(line); if (!message || seen.has(message)) continue; seen.add(message); out.push({ source: "docker", kind, severity: "error", message }); } return out; } function exitOf(raw: string): number | null { const m = /===SU:EXIT=(\d+)===/.exec(raw); return m ? Number(m[1]) : null; } export interface DockerApplyParsed { recreated: string[]; running: string[]; exited: string[]; imagesAfter: { id: string | null; digests: string | null }[]; errors: SnapshotError[]; exitCode: number | null; } export function parseDockerApply(raw: string): DockerApplyParsed { const applySec = section(raw, "===SU:DOCKER_APPLY===", "===SU:DOCKER_PS_AFTER==="); const psSec = section(raw, "===SU:DOCKER_PS_AFTER===", "===SU:DOCKER_INSPECT_AFTER==="); const inspectSec = section(raw, "===SU:DOCKER_INSPECT_AFTER===", "===SU:EXIT="); const recreated = new Set(); for (const m of applySec.matchAll(/Container\s+(\S+)\s+(Recreated|Created)\s*$/gm)) { if (m[1]) recreated.add(m[1]); } const running: string[] = []; const exited: string[] = []; const psLines = psSec.trim(); const records: { Name?: string; State?: string }[] = []; if (psLines.startsWith("[")) { try { records.push(...(JSON.parse(psLines) as typeof records)); } catch { /* ignore */ } } else { for (const line of psLines.split("\n")) { const t = line.trim(); if (!t.startsWith("{")) continue; try { records.push(JSON.parse(t)); } catch { /* ignore */ } } } for (const r of records) { if (!r.Name) continue; if (r.State === "running") running.push(r.Name); else if (r.State === "exited") exited.push(r.Name); } const imagesAfter: DockerApplyParsed["imagesAfter"] = []; for (const line of inspectSec.split("\n")) { if (!line.startsWith("IMG\t")) continue; const parts = line.split("\t"); imagesAfter.push({ id: parts[1] || null, digests: parts[2] || null }); } return { recreated: [...recreated], running, exited, imagesAfter, errors: collectErrors(applySec, "compose_apply_failed"), exitCode: exitOf(raw), }; } /** Convertit une taille humaine Docker (décimale) en octets. */ export function parseHumanBytes(s: string): number { const m = /([\d.]+)\s*([kKMGTP]?i?B)/.exec(s.trim()); if (!m) return 0; const value = Number(m[1]); if (!Number.isFinite(value)) return 0; const unit = (m[2] ?? "B").toUpperCase(); const mult: Record = { B: 1, KB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12, PB: 1e15, }; return Math.round(value * (mult[unit] ?? 1)); } export interface DockerPruneParsed { imagesDeleted: string[]; bytesReclaimed: number; errors: SnapshotError[]; exitCode: number | null; } export function parseDockerPrune(raw: string): DockerPruneParsed { const sec = section(raw, "===SU:DOCKER_PRUNE===", "===SU:EXIT="); const imagesDeleted: string[] = []; let bytesReclaimed = 0; for (const line of sec.split("\n")) { const del = /^deleted:\s+(\S+)/.exec(line.trim()); if (del?.[1]) imagesDeleted.push(del[1]); const total = /Total reclaimed space:\s*(.+)$/.exec(line); if (total?.[1]) bytesReclaimed = parseHumanBytes(total[1]); } return { imagesDeleted, bytesReclaimed, errors: collectErrors(sec, "prune_failed"), exitCode: exitOf(raw) }; } export interface DockerDownParsed { removed: string[]; errors: SnapshotError[]; exitCode: number | null; } export function parseDockerDown(raw: string): DockerDownParsed { const sec = section(raw, "===SU:DOCKER_DOWN===", "===SU:EXIT="); const removed = new Set(); for (const m of sec.matchAll(/Container\s+(\S+)\s+Removed\s*$/gm)) { if (m[1]) removed.add(m[1]); } return { removed: [...removed], errors: collectErrors(sec, "compose_down_failed"), exitCode: exitOf(raw) }; } // ---------------------------------------------------------------------------- // Orchestration (SSH). Réservé aux stacks `enabled` ; déclenché via action_requests. // ---------------------------------------------------------------------------- function getEnabledStack(machineId: string, stackId: string) { const stack = db .select() .from(schema.dockerComposeStacks) .where(eq(schema.dockerComposeStacks.id, stackId)) .get(); if (!stack || stack.machineId !== machineId) throw new Error("Stack introuvable"); if (stack.status !== "enabled") throw new Error(`Stack non activé (statut ${stack.status})`); return stack; } async function runDockerScript( machineId: string, rel: string, vars: Record, onData?: (c: string) => void, ): Promise { const m = getMachineRow(machineId); if (!m) throw new Error("Machine introuvable"); const script = renderTemplate(rel, vars); const res = await runScriptSudo( getCreds(m), script, (c) => { onData?.(c); outputHub.publish(machineId, c); }, 900000, ); return res.stdout; } export interface ApplyOutcome { parsed: DockerApplyParsed; raw: string; stackName: string; events: typeof schema.dockerImageEvents.$inferInsert[]; } /** `docker compose up -d --remove-orphans` sur un stack enabled + persistance des events. */ export async function applyStack( machineId: string, stackId: string, executionId: string, onData?: (c: string) => void, ): Promise { const stack = getEnabledStack(machineId, stackId); const raw = await runDockerScript(machineId, "docker/apply-compose.sh.tpl", { stackDir: stack.workingDir }, onData); const parsed = parseDockerApply(raw); const now = new Date().toISOString(); const events = parsed.recreated.map((name) => ({ id: randomUUID(), executionId, machineId, stackId, serviceName: name, imageRef: null, fromImageId: null, toImageId: null, fromDigest: null, toDigest: null, operation: "recreated", bytesReclaimed: null, createdAt: now, })); for (const ev of events) db.insert(schema.dockerImageEvents).values(ev).run(); db.update(schema.dockerComposeStacks) .set({ lastUpdateAt: now, updatedAt: now }) .where(eq(schema.dockerComposeStacks.id, stackId)) .run(); return { parsed, raw, stackName: stack.name, events }; } export interface PruneOutcome { parsed: DockerPruneParsed; raw: string; } /** `docker image prune` (safe par défaut, agressif si demandé) + event pruned. */ export async function pruneImages( machineId: string, executionId: string, aggressive: boolean, onData?: (c: string) => void, ): Promise { const raw = await runDockerScript(machineId, "docker/prune-images.sh.tpl", { aggressive }, onData); const parsed = parseDockerPrune(raw); if (parsed.imagesDeleted.length > 0 || parsed.bytesReclaimed > 0) { db.insert(schema.dockerImageEvents) .values({ id: randomUUID(), executionId, machineId, stackId: null, serviceName: null, imageRef: null, fromImageId: null, toImageId: null, fromDigest: null, toDigest: null, operation: "pruned", bytesReclaimed: parsed.bytesReclaimed, createdAt: new Date().toISOString(), }) .run(); } return { parsed, raw }; } export interface DownOutcome { parsed: DockerDownParsed; raw: string; stackName: string; } /** `docker compose down` (sans volumes/rmi) sur un stack enabled. */ export async function downStack( machineId: string, stackId: string, onData?: (c: string) => void, ): Promise { const stack = getEnabledStack(machineId, stackId); const raw = await runDockerScript(machineId, "docker/down-compose.sh.tpl", { stackDir: stack.workingDir }, onData); const parsed = parseDockerDown(raw); return { parsed, raw, stackName: stack.name }; }