Files
system_update/server/services/dockerScan.ts
T
gilles 2af8e74079 feat(docker): scan/inspect passifs des stacks Compose (tâche 2 SJ-4)
- 4 tables Docker (settings/compose_roots/compose_stacks/stack_services)
  + migration 0004 (timestamps journal monotones)
- templates docker/scan-compose + inspect-compose ; renderTemplate bascule
  sur délimiteurs <% %> pour les templates docker/ afin de préserver les
  Go-templates {{.ID}} intacts
- dockerScan: parseDockerScan (TDD) + scanDockerStacks (persiste stacks
  candidats, complète la détection par labels)
- action docker_scan branchée dans execute (route dédiée, archivage report/log)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:54:52 +02:00

134 lines
4.5 KiB
TypeScript

// 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<string, string> {
const out: Record<string, string> = {};
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);
}
/** 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<DockerScanResult> {
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;
}