Files
system_update/server/services/dockerApply.ts
T
gilles edb22a59c7 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>
2026-06-06 06:05:59 +02:00

290 lines
8.9 KiB
TypeScript

// server/services/dockerApply.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
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";
import { cleanDockerError } from "./dockerPull.js";
import type { SnapshotError } from "@shared/types.js";
// ----------------------------------------------------------------------------
// Fonctions pures (testables).
// ----------------------------------------------------------------------------
function section(raw: string, start: string, end?: string): string {
const i = raw.indexOf(start);
if (i < 0) return "";
const from = i + start.length;
const j = end ? raw.indexOf(end, from) : -1;
return raw.slice(from, j < 0 ? undefined : j);
}
const ERROR_RE = /\b(error|unauthorized|denied|forbidden|failed|no such host|connection refused|timeout|cannot)\b/i;
function collectErrors(text: string, kind: string): SnapshotError[] {
const seen = new Set<string>();
const out: SnapshotError[] = [];
for (const line of text.split("\n")) {
if (!ERROR_RE.test(line)) continue;
const message = cleanDockerError(line);
if (!message || seen.has(message)) continue;
seen.add(message);
out.push({ source: "docker", kind, severity: "error", message });
}
return out;
}
function exitOf(raw: string): number | null {
const m = /===SU:EXIT=(\d+)===/.exec(raw);
return m ? Number(m[1]) : null;
}
export interface DockerApplyParsed {
recreated: string[];
running: string[];
exited: string[];
imagesAfter: { id: string | null; digests: string | null }[];
errors: SnapshotError[];
exitCode: number | null;
}
export function parseDockerApply(raw: string): DockerApplyParsed {
const applySec = section(raw, "===SU:DOCKER_APPLY===", "===SU:DOCKER_PS_AFTER===");
const psSec = section(raw, "===SU:DOCKER_PS_AFTER===", "===SU:DOCKER_INSPECT_AFTER===");
const inspectSec = section(raw, "===SU:DOCKER_INSPECT_AFTER===", "===SU:EXIT=");
const recreated = new Set<string>();
for (const m of applySec.matchAll(/Container\s+(\S+)\s+(Recreated|Created)\s*$/gm)) {
if (m[1]) recreated.add(m[1]);
}
const running: string[] = [];
const exited: string[] = [];
const psLines = psSec.trim();
const records: { Name?: string; State?: string }[] = [];
if (psLines.startsWith("[")) {
try {
records.push(...(JSON.parse(psLines) as typeof records));
} catch {
/* ignore */
}
} else {
for (const line of psLines.split("\n")) {
const t = line.trim();
if (!t.startsWith("{")) continue;
try {
records.push(JSON.parse(t));
} catch {
/* ignore */
}
}
}
for (const r of records) {
if (!r.Name) continue;
if (r.State === "running") running.push(r.Name);
else if (r.State === "exited") exited.push(r.Name);
}
const imagesAfter: DockerApplyParsed["imagesAfter"] = [];
for (const line of inspectSec.split("\n")) {
if (!line.startsWith("IMG\t")) continue;
const parts = line.split("\t");
imagesAfter.push({ id: parts[1] || null, digests: parts[2] || null });
}
return {
recreated: [...recreated],
running,
exited,
imagesAfter,
errors: collectErrors(applySec, "compose_apply_failed"),
exitCode: exitOf(raw),
};
}
/** Convertit une taille humaine Docker (décimale) en octets. */
export function parseHumanBytes(s: string): number {
const m = /([\d.]+)\s*([kKMGTP]?i?B)/.exec(s.trim());
if (!m) return 0;
const value = Number(m[1]);
if (!Number.isFinite(value)) return 0;
const unit = (m[2] ?? "B").toUpperCase();
const mult: Record<string, number> = {
B: 1,
KB: 1e3,
MB: 1e6,
GB: 1e9,
TB: 1e12,
PB: 1e15,
};
return Math.round(value * (mult[unit] ?? 1));
}
export interface DockerPruneParsed {
imagesDeleted: string[];
bytesReclaimed: number;
errors: SnapshotError[];
exitCode: number | null;
}
export function parseDockerPrune(raw: string): DockerPruneParsed {
const sec = section(raw, "===SU:DOCKER_PRUNE===", "===SU:EXIT=");
const imagesDeleted: string[] = [];
let bytesReclaimed = 0;
for (const line of sec.split("\n")) {
const del = /^deleted:\s+(\S+)/.exec(line.trim());
if (del?.[1]) imagesDeleted.push(del[1]);
const total = /Total reclaimed space:\s*(.+)$/.exec(line);
if (total?.[1]) bytesReclaimed = parseHumanBytes(total[1]);
}
return { imagesDeleted, bytesReclaimed, errors: collectErrors(sec, "prune_failed"), exitCode: exitOf(raw) };
}
export interface DockerDownParsed {
removed: string[];
errors: SnapshotError[];
exitCode: number | null;
}
export function parseDockerDown(raw: string): DockerDownParsed {
const sec = section(raw, "===SU:DOCKER_DOWN===", "===SU:EXIT=");
const removed = new Set<string>();
for (const m of sec.matchAll(/Container\s+(\S+)\s+Removed\s*$/gm)) {
if (m[1]) removed.add(m[1]);
}
return { removed: [...removed], errors: collectErrors(sec, "compose_down_failed"), exitCode: exitOf(raw) };
}
// ----------------------------------------------------------------------------
// Orchestration (SSH). Réservé aux stacks `enabled` ; déclenché via action_requests.
// ----------------------------------------------------------------------------
function getEnabledStack(machineId: string, stackId: string) {
const stack = db
.select()
.from(schema.dockerComposeStacks)
.where(eq(schema.dockerComposeStacks.id, stackId))
.get();
if (!stack || stack.machineId !== machineId) throw new Error("Stack introuvable");
if (stack.status !== "enabled") throw new Error(`Stack non activé (statut ${stack.status})`);
return stack;
}
async function runDockerScript(
machineId: string,
rel: string,
vars: Record<string, unknown>,
onData?: (c: string) => void,
): Promise<string> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
const script = renderTemplate(rel, vars);
const res = await runScriptSudo(
getCreds(m),
script,
(c) => {
onData?.(c);
outputHub.publish(machineId, c);
},
900000,
);
return res.stdout;
}
export interface ApplyOutcome {
parsed: DockerApplyParsed;
raw: string;
stackName: string;
events: typeof schema.dockerImageEvents.$inferInsert[];
}
/** `docker compose up -d --remove-orphans` sur un stack enabled + persistance des events. */
export async function applyStack(
machineId: string,
stackId: string,
executionId: string,
onData?: (c: string) => void,
): Promise<ApplyOutcome> {
const stack = getEnabledStack(machineId, stackId);
const raw = await runDockerScript(machineId, "docker/apply-compose.sh.tpl", { stackDir: stack.workingDir }, onData);
const parsed = parseDockerApply(raw);
const now = new Date().toISOString();
const events = parsed.recreated.map((name) => ({
id: randomUUID(),
executionId,
machineId,
stackId,
serviceName: name,
imageRef: null,
fromImageId: null,
toImageId: null,
fromDigest: null,
toDigest: null,
operation: "recreated",
bytesReclaimed: null,
createdAt: now,
}));
for (const ev of events) db.insert(schema.dockerImageEvents).values(ev).run();
db.update(schema.dockerComposeStacks)
.set({ lastUpdateAt: now, updatedAt: now })
.where(eq(schema.dockerComposeStacks.id, stackId))
.run();
return { parsed, raw, stackName: stack.name, events };
}
export interface PruneOutcome {
parsed: DockerPruneParsed;
raw: string;
}
/** `docker image prune` (safe par défaut, agressif si demandé) + event pruned. */
export async function pruneImages(
machineId: string,
executionId: string,
aggressive: boolean,
onData?: (c: string) => void,
): Promise<PruneOutcome> {
const raw = await runDockerScript(machineId, "docker/prune-images.sh.tpl", { aggressive }, onData);
const parsed = parseDockerPrune(raw);
if (parsed.imagesDeleted.length > 0 || parsed.bytesReclaimed > 0) {
db.insert(schema.dockerImageEvents)
.values({
id: randomUUID(),
executionId,
machineId,
stackId: null,
serviceName: null,
imageRef: null,
fromImageId: null,
toImageId: null,
fromDigest: null,
toDigest: null,
operation: "pruned",
bytesReclaimed: parsed.bytesReclaimed,
createdAt: new Date().toISOString(),
})
.run();
}
return { parsed, raw };
}
export interface DownOutcome {
parsed: DockerDownParsed;
raw: string;
stackName: string;
}
/** `docker compose down` (sans volumes/rmi) sur un stack enabled. */
export async function downStack(
machineId: string,
stackId: string,
onData?: (c: string) => void,
): Promise<DownOutcome> {
const stack = getEnabledStack(machineId, stackId);
const raw = await runDockerScript(machineId, "docker/down-compose.sh.tpl", { stackDir: stack.workingDir }, onData);
const parsed = parseDockerDown(raw);
return { parsed, raw, stackName: stack.name };
}