2c15b8c06b
Rend le flux Docker déclenchable via l'API (prérequis SJ-5/SJ-6) : - GET /machines/:id/docker/settings — settings + racines Compose - POST /machines/:id/docker/roots — déclare/active les racines à scanner - POST /machines/:id/docker/scan — scan passif (background, WS) - GET /machines/:id/docker/stacks — liste stacks + services - PATCH /machines/:id/docker/stacks/:stackId — cycle candidate→enabled→ignored dockerScan: getDockerSettings, listStacks, setStackStatus. Les actions pull-check/apply/down restent réservées aux stacks enabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
6.5 KiB
TypeScript
199 lines
6.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);
|
|
}
|
|
|
|
/** 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<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;
|
|
}
|