feat(docker): pull-check + comparaison déterministe par stack (tâche 2 SJ-5)

- template docker/pull-check.sh.tpl (pull sans up, inspect before/after)
- dockerPull: parseDockerPullCheck + buildDockerPullResult (TDD) — compare
  image id/digest/label OCI → services up_to_date|updates_available|error,
  changes operation=pulled ; erreurs registry nettoyées (URL/token/password)
- dockerDedupKey (digests prioritaires, fallback image ids) + DockerImageChange.dedupKey
- pullCheckStack: SSH + upsert docker_stack_services, refuse stack non enabled,
  refresh Docker séparé (hors refreshMachine, pas de pull auto)
- execute: runAction(opts.stackId), branche docker_pull_check, injection stackDir
  (corrige docker_inspect_current) ; route: allowlist Docker passifs + pull_check,
  destructives toujours hors API jusqu'à action_requests (SJ-6)

Pas de migration (schéma SJ-4 suffisant). tsc 0 erreur · 85 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:02:38 +02:00
parent 2af8e74079
commit b1c81ba518
7 changed files with 554 additions and 5 deletions
+97 -2
View File
@@ -30,7 +30,15 @@ const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
docker_inspect_current: "docker/inspect-compose.sh.tpl",
};
export async function runAction(machineId: string, action: ActionType): Promise<ExecutionResult> {
export interface RunActionOpts {
stackId?: string;
}
export async function runAction(
machineId: string,
action: ActionType,
opts?: RunActionOpts,
): Promise<ExecutionResult> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
@@ -112,10 +120,97 @@ export async function runAction(machineId: string, action: ActionType): Promise<
return resultDocker;
}
// --- SJ-5 : docker_pull_check délégué au service dédié (pull + comparaison + persistance) ---
if (action === "docker_pull_check") {
if (!opts?.stackId) throw new Error("docker_pull_check requiert un stackId");
const { pullCheckStack, dockerDedupKey } = await import("./dockerPull.js");
let rawPull = "";
let pullStatus: ExecutionStatus = "ok";
let importantPull: string[] = [];
let dockerExec: ExecutionResult["docker"];
try {
const outcome = await pullCheckStack(machineId, opts.stackId, (c) => {
rawPull += c;
});
rawPull = outcome.raw;
const r = outcome.result;
pullStatus = r.status === "error" ? "error" : r.status === "warning" ? "warning" : "ok";
const changes = r.changes.map((ch) => ({
...ch,
dedupKey: dockerDedupKey(ch.imageRef ?? ch.stack, ch.fromDigest ?? null, ch.toDigest ?? null, ch.fromImageId ?? null, ch.toImageId ?? null),
}));
dockerExec = { pull: { changes, ...(r.errors.length ? { errors: r.errors } : {}) } };
importantPull = [
`docker_pull_check ${outcome.stackName} : ${r.changes.length} image(s) mise(s) à jour (${r.services.length} service(s))`,
...r.services.map((s) => ` ${s.status} ${s.image}`),
...r.errors.map((e) => ` [${e.kind}] ${e.message}`),
];
outputHub.publish(machineId, `\n===SU:DONE status=${pullStatus} changes=${r.changes.length}===\n`);
} catch (err) {
pullStatus = "error";
importantPull = [`[ERREUR] ${(err as Error).message}`];
rawPull += `\n[ERREUR] ${(err as Error).message}\n`;
outputHub.publish(machineId, `\n[ERREUR] ${(err as Error).message}\n`);
}
const finishedAtPull = new Date().toISOString();
const dirPull = join(env.reportsDir, machineId);
mkdirSync(dirPull, { recursive: true });
const rawLogPathPull = join(dirPull, `${executionId}.log`);
const reportPathPull = join(dirPull, `${executionId}.md`);
writeFileSync(rawLogPathPull, rawPull || importantPull.join("\n") + "\n", "utf8");
const resultPull: ExecutionResult = {
executionId, machineId, startedAt, finishedAt: finishedAtPull,
mode: "manual", action, status: pullStatus,
rebootRequiredAfterRun: false,
importantLogLines: importantPull,
rawLogRef: rawLogPathPull, reportRef: reportPathPull,
...(dockerExec ? { docker: dockerExec } : {}),
};
writeFileSync(reportPathPull, buildReportMarkdown(resultPull, m.name), "utf8");
const reportIdPull = randomUUID();
db.update(schema.executions).set({
finishedAt: finishedAtPull, status: pullStatus, schemaVersion: 1,
resultJson: JSON.stringify(resultPull), importantJson: JSON.stringify(importantPull),
reportPath: reportPathPull, rawLogPath: rawLogPathPull, reportId: reportIdPull,
exitCode: pullStatus === "ok" ? 0 : 1,
errorKind: pullStatus === "error" ? "execution_failed" : null,
errorMessage: pullStatus === "error" ? (importantPull.at(-1) ?? null) : null,
}).where(eq(schema.executions.id, executionId)).run();
db.update(schema.machines).set({ status: pullStatus === "error" ? "error" : "unknown" })
.where(eq(schema.machines.id, machineId)).run();
db.insert(schema.reports).values({
id: reportIdPull, machineId, executionId, kind: "machine",
title: `${m.name} — docker_pull_check`, path: reportPathPull, createdAt: finishedAtPull,
}).run();
db.insert(schema.rawArtifacts).values({
id: randomUUID(), machineId, kind: "raw_log", path: rawLogPathPull,
bytes: statSync(rawLogPathPull).size, createdAt: finishedAtPull,
retentionPolicy: pullStatus === "error" ? "failed" : "default",
}).run();
upsertMachineState(machineId, {
status: pullStatus === "error" ? "error" : "unknown",
runningJobId: null,
lastErrorKind: pullStatus === "error" ? "execution_failed" : null,
lastErrorMessage: pullStatus === "error" ? (importantPull.at(-1) ?? null) : null,
});
recordEvent({
machineId, eventType: "action_docker_pull_check",
severity: pullStatus === "error" ? "error" : "info",
executionId, message: `Action docker_pull_check : ${pullStatus}`,
});
return resultPull;
}
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const rel = TEMPLATE_FOR[action];
if (!rel) throw new Error("Action sans template: " + action);
const script = renderTemplate(rel, { aptProxy: proxy });
// Templates Docker par-stack (inspect) : injecter stackDir ; ignoré par les templates APT.
let stackDir: string | null = null;
if (opts?.stackId) {
const st = db.select().from(schema.dockerComposeStacks).where(eq(schema.dockerComposeStacks.id, opts.stackId)).get();
stackDir = st?.workingDir ?? null;
}
const script = renderTemplate(rel, { aptProxy: proxy, stackDir });
const inactivity = action === "reboot" ? 0 : 600000;