feat(docker): apply/prune/down + socle action_requests (tâche 2 SJ-6)
- migration 0005 : tables docker_image_events + action_requests - templates apply-compose (up -d --remove-orphans), prune-images (safe/agressif), down-compose (sans volumes/rmi) - dockerApply: parsers TDD (apply recreated/running/exited, prune images+bytes, down removed, parseHumanBytes) + orchestration applyStack/pruneImages/downStack réservée aux stacks enabled, insère docker_image_events - actionRequests: create/approve/reject/list — actions destructives validées explicitement (Hermes propose, opérateur approuve, run en arrière-plan) ; hors API directe (POST /:id/actions reste passif uniquement) - routes /machines/:id/action-requests + /action-requests/:id[/approve|/reject] - execute: RunActionOpts.aggressive, branches apply/prune/down, helper archiveExecution mutualisant le boilerplate d'archivage tsc 0 erreur · 91 tests · build OK · boot OK (migrations 0000→0005). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,74 @@ const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
|
||||
|
||||
export interface RunActionOpts {
|
||||
stackId?: string;
|
||||
aggressive?: boolean; // docker_prune_images
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive une exécution terminée (log brut + rapport + lignes DB + état machine +
|
||||
* event) et renvoie l'ExecutionResult. Mutualise le boilerplate des branches Docker.
|
||||
*/
|
||||
function archiveExecution(args: {
|
||||
machineId: string;
|
||||
machineName: string;
|
||||
executionId: string;
|
||||
action: ActionType;
|
||||
startedAt: string;
|
||||
status: ExecutionStatus;
|
||||
raw: string;
|
||||
importantLines: string[];
|
||||
docker?: ExecutionResult["docker"];
|
||||
errors?: ExecutionResult["errors"];
|
||||
}): ExecutionResult {
|
||||
const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args;
|
||||
const finishedAt = new Date().toISOString();
|
||||
const dir = join(env.reportsDir, machineId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const rawLogPath = join(dir, `${executionId}.log`);
|
||||
const reportPath = join(dir, `${executionId}.md`);
|
||||
writeFileSync(rawLogPath, raw || importantLines.join("\n") + "\n", "utf8");
|
||||
const result: ExecutionResult = {
|
||||
executionId, machineId, startedAt, finishedAt, mode: "manual", action, status,
|
||||
rebootRequiredAfterRun: false,
|
||||
importantLogLines: importantLines,
|
||||
rawLogRef: rawLogPath, reportRef: reportPath,
|
||||
...(args.docker ? { docker: args.docker } : {}),
|
||||
...(args.errors && args.errors.length ? { errors: args.errors } : {}),
|
||||
};
|
||||
writeFileSync(reportPath, buildReportMarkdown(result, machineName), "utf8");
|
||||
const reportId = randomUUID();
|
||||
db.update(schema.executions).set({
|
||||
finishedAt, status, schemaVersion: 1,
|
||||
resultJson: JSON.stringify(result), importantJson: JSON.stringify(importantLines),
|
||||
reportPath, rawLogPath, reportId,
|
||||
exitCode: status === "ok" ? 0 : 1,
|
||||
errorKind: status === "error" ? "execution_failed" : null,
|
||||
errorMessage: status === "error" ? (importantLines.at(-1) ?? null) : null,
|
||||
}).where(eq(schema.executions.id, executionId)).run();
|
||||
db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" })
|
||||
.where(eq(schema.machines.id, machineId)).run();
|
||||
db.insert(schema.reports).values({
|
||||
id: reportId, machineId, executionId, kind: "machine",
|
||||
title: `${machineName} — ${action}`, path: reportPath, createdAt: finishedAt,
|
||||
}).run();
|
||||
db.insert(schema.rawArtifacts).values({
|
||||
id: randomUUID(), machineId, kind: "raw_log", path: rawLogPath,
|
||||
bytes: statSync(rawLogPath).size, createdAt: finishedAt,
|
||||
retentionPolicy: status === "error" ? "failed" : "default",
|
||||
}).run();
|
||||
upsertMachineState(machineId, {
|
||||
status: status === "error" ? "error" : "unknown",
|
||||
runningJobId: null,
|
||||
lastErrorKind: status === "error" ? "execution_failed" : null,
|
||||
lastErrorMessage: status === "error" ? (importantLines.at(-1) ?? null) : null,
|
||||
});
|
||||
recordEvent({
|
||||
machineId, eventType: `action_${action}`,
|
||||
severity: status === "error" ? "error" : "info",
|
||||
executionId, message: `Action ${action} : ${status}`,
|
||||
});
|
||||
outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runAction(
|
||||
@@ -201,6 +269,70 @@ export async function runAction(
|
||||
return resultPull;
|
||||
}
|
||||
|
||||
// --- SJ-6 : actions Docker destructives (apply / prune / down) ---
|
||||
if (action === "docker_compose_apply") {
|
||||
if (!opts?.stackId) throw new Error("docker_compose_apply requiert un stackId");
|
||||
const { applyStack } = await import("./dockerApply.js");
|
||||
try {
|
||||
const o = await applyStack(machineId, opts.stackId, executionId, (c) => outputHub.publish(machineId, c));
|
||||
const p = o.parsed;
|
||||
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
|
||||
const important = [
|
||||
`docker_compose_apply ${o.stackName} : ${p.recreated.length} recréé(s), ${p.running.length} running, ${p.exited.length} exited`,
|
||||
...p.recreated.map((n) => ` recreated ${n}`),
|
||||
...p.exited.map((n) => ` exited ${n}`),
|
||||
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
|
||||
];
|
||||
return archiveExecution({
|
||||
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
|
||||
importantLines: important, docker: { up: { recreated: p.recreated, running: p.running, exited: p.exited, ...(p.errors.length ? { errors: p.errors } : {}) } },
|
||||
});
|
||||
} catch (err) {
|
||||
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "docker_prune_images") {
|
||||
const { pruneImages } = await import("./dockerApply.js");
|
||||
try {
|
||||
const o = await pruneImages(machineId, executionId, !!opts?.aggressive, (c) => outputHub.publish(machineId, c));
|
||||
const p = o.parsed;
|
||||
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
|
||||
const mb = (p.bytesReclaimed / 1e6).toFixed(1);
|
||||
const important = [
|
||||
`docker_prune_images (${opts?.aggressive ? "agressif" : "safe"}) : ${p.imagesDeleted.length} image(s), ${mb} Mo récupérés`,
|
||||
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
|
||||
];
|
||||
return archiveExecution({
|
||||
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
|
||||
importantLines: important, docker: { prune: { imagesDeleted: p.imagesDeleted, bytesReclaimed: p.bytesReclaimed, ...(p.errors.length ? { errors: p.errors } : {}) } },
|
||||
});
|
||||
} catch (err) {
|
||||
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "docker_compose_down") {
|
||||
if (!opts?.stackId) throw new Error("docker_compose_down requiert un stackId");
|
||||
const { downStack } = await import("./dockerApply.js");
|
||||
try {
|
||||
const o = await downStack(machineId, opts.stackId, (c) => outputHub.publish(machineId, c));
|
||||
const p = o.parsed;
|
||||
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
|
||||
const important = [
|
||||
`docker_compose_down ${o.stackName} : ${p.removed.length} conteneur(s) retiré(s)`,
|
||||
...p.removed.map((n) => ` removed ${n}`),
|
||||
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
|
||||
];
|
||||
return archiveExecution({
|
||||
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
|
||||
importantLines: important, errors: p.errors,
|
||||
});
|
||||
} catch (err) {
|
||||
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
||||
}
|
||||
}
|
||||
|
||||
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
|
||||
const rel = TEMPLATE_FOR[action];
|
||||
if (!rel) throw new Error("Action sans template: " + action);
|
||||
|
||||
Reference in New Issue
Block a user