edb22a59c7
- 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>
290 lines
8.9 KiB
TypeScript
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 };
|
|
}
|