2af8e74079
- 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>
134 lines
4.5 KiB
TypeScript
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;
|
|
}
|