// server/services/dockerScan.ts import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { basename } from "node:path"; 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"; export interface DockerScanResult { stacks: { workingDir: string; composeFile: string; valid: boolean }[]; active: { project: string; workingDir: string }[]; } function fields(line: string): Record { const out: Record = {}; for (const part of line.split("\t")) { const i = part.indexOf("="); if (i > 0) out[part.slice(0, i)] = part.slice(i + 1); } return out; } export function parseDockerScan(raw: string): DockerScanResult { const stacks: DockerScanResult["stacks"] = []; const active: DockerScanResult["active"] = []; for (const line of raw.split("\n")) { const l = line.trimEnd(); if (l.startsWith("STACK_OK\t") || l.startsWith("STACK_INVALID\t")) { const f = fields(l); stacks.push({ workingDir: f.dir ?? "", composeFile: f.file ?? "", valid: l.startsWith("STACK_OK") }); } else if (l.startsWith("ACTIVE\t")) { const f = fields(l); active.push({ project: f.project ?? "", workingDir: f.working_dir ?? "" }); } } return { stacks, active }; } /** Racines Compose déclarées (enabled) d'une machine. */ export function getComposeRoots(machineId: string): string[] { return db .select() .from(schema.dockerComposeRoots) .where(eq(schema.dockerComposeRoots.machineId, machineId)) .all() .filter((r) => r.enabled) .map((r) => r.path); } /** Paramètres Docker + racines déclarées d'une machine. */ export function getDockerSettings(machineId: string) { const settings = db .select() .from(schema.dockerSettings) .where(eq(schema.dockerSettings.machineId, machineId)) .get(); const roots = db .select() .from(schema.dockerComposeRoots) .where(eq(schema.dockerComposeRoots.machineId, machineId)) .all(); return { settings: settings ?? null, roots }; } /** Liste les stacks d'une machine avec leurs services. */ export function listStacks(machineId: string) { const stacks = db .select() .from(schema.dockerComposeStacks) .where(eq(schema.dockerComposeStacks.machineId, machineId)) .all(); return stacks.map((s) => ({ ...s, composeFiles: safeParseArray(s.composeFilesJson), services: db .select() .from(schema.dockerStackServices) .where(eq(schema.dockerStackServices.stackId, s.id)) .all(), })); } const STACK_STATUSES = ["candidate", "enabled", "ignored", "error"] as const; export type StackStatus = (typeof STACK_STATUSES)[number]; /** Change le cycle de vie d'un stack (candidate → enabled → …). */ export function setStackStatus(machineId: string, stackId: string, status: StackStatus) { if (!STACK_STATUSES.includes(status)) throw new Error(`Statut invalide : ${status}`); const stack = db .select() .from(schema.dockerComposeStacks) .where(eq(schema.dockerComposeStacks.id, stackId)) .get(); if (!stack || stack.machineId !== machineId) throw new Error("Stack introuvable"); db.update(schema.dockerComposeStacks) .set({ status, updatedAt: new Date().toISOString() }) .where(eq(schema.dockerComposeStacks.id, stackId)) .run(); return db .select() .from(schema.dockerComposeStacks) .where(eq(schema.dockerComposeStacks.id, stackId)) .get(); } function safeParseArray(json: string): string[] { try { const v = JSON.parse(json); return Array.isArray(v) ? (v as string[]) : []; } catch { return []; } } /** Déclare/active Docker pour une machine + ses racines Compose (idempotent). */ export function setDockerRoots(machineId: string, paths: string[], scanDepth = 4): void { const now = new Date().toISOString(); db.insert(schema.dockerSettings) .values({ machineId, enabled: 1, scanDepth, pruneMode: "safe", updatedAt: now }) .onConflictDoUpdate({ target: schema.dockerSettings.machineId, set: { enabled: 1, scanDepth, updatedAt: now } }) .run(); db.delete(schema.dockerComposeRoots).where(eq(schema.dockerComposeRoots.machineId, machineId)).run(); for (const path of paths) { db.insert(schema.dockerComposeRoots).values({ id: randomUUID(), machineId, path, enabled: 1, createdAt: now, updatedAt: now, }).run(); } } /** Scanne les racines déclarées et upsert les stacks candidats. Renvoie le résultat parsé. */ export async function scanDockerStacks(machineId: string): Promise { const m = getMachineRow(machineId); if (!m) throw new Error("Machine introuvable"); const roots = getComposeRoots(machineId); const settings = db .select() .from(schema.dockerSettings) .where(eq(schema.dockerSettings.machineId, machineId)) .get(); const depth = settings?.scanDepth ?? 4; if (roots.length === 0) return { stacks: [], active: [] }; const script = renderTemplate("docker/scan-compose.sh.tpl", { composeRoots: roots.join(" "), composeScanDepth: depth, }); let raw = ""; const res = await runScriptSudo(getCreds(m), script, (c) => { raw += c; outputHub.publish(machineId, c); }); raw = res.stdout; const parsed = parseDockerScan(raw); const now = new Date().toISOString(); const activeDirs = new Set(parsed.active.map((a) => a.workingDir)); for (const s of parsed.stacks) { if (!s.valid) continue; const name = basename(s.workingDir); const existing = db .select() .from(schema.dockerComposeStacks) .where(eq(schema.dockerComposeStacks.workingDir, s.workingDir)) .get(); const detectedBy = activeDirs.has(s.workingDir) ? "label" : "root_scan"; if (existing) { db.update(schema.dockerComposeStacks) .set({ lastScanAt: now, detectedBy, updatedAt: now }) .where(eq(schema.dockerComposeStacks.id, existing.id)) .run(); } else { db.insert(schema.dockerComposeStacks).values({ id: randomUUID(), machineId, name, workingDir: s.workingDir, composeFilesJson: JSON.stringify([s.composeFile]), status: "candidate", detectedBy, lastScanAt: now, createdAt: now, updatedAt: now, }).run(); } } db.update(schema.dockerSettings) .set({ lastScanAt: now, updatedAt: now }) .where(eq(schema.dockerSettings.machineId, machineId)) .run(); return parsed; }