Files
system_update/server/services/dockerPull.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

254 lines
8.3 KiB
TypeScript

// server/services/dockerPull.ts
import { randomUUID } from "node:crypto";
import { and, 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 type {
DockerImageChange,
DockerSnapshotService,
SnapshotError,
SnapshotStatus,
} from "@shared/types.js";
// ----------------------------------------------------------------------------
// Fonctions pures (testables) : parsing + comparaison déterministe.
// ----------------------------------------------------------------------------
interface ImageInspect {
id: string | null;
digest: string | null;
version?: string | null;
}
export interface DockerPullParsed {
before: Record<string, ImageInspect>;
after: Record<string, ImageInspect>;
pullErrors: SnapshotError[];
exitCode: number | null;
}
export interface DockerPullResult {
services: DockerSnapshotService[];
changes: DockerImageChange[];
errors: SnapshotError[];
status: SnapshotStatus;
exitCode: number | null;
}
const nz = (s: string | undefined): string | null => (s && s.length ? s : null);
/** Retire URLs et secrets (token/bearer/password) d'une ligne d'erreur Docker. */
export function cleanDockerError(line: string): string {
return line
.replace(/https?:\/\/\S+/gi, "<url>")
.replace(/\b(token|bearer|authorization|auth|password|passwd|secret|key)=\S+/gi, "$1=<redacted>")
.replace(/\bBearer\s+\S+/gi, "Bearer <redacted>")
.trim();
}
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);
}
export function parseDockerPullCheck(raw: string): DockerPullParsed {
const before: Record<string, ImageInspect> = {};
const after: Record<string, ImageInspect> = {};
for (const line of section(raw, "===SU:DOCKER_INSPECT_BEFORE===", "===SU:DOCKER_PULL===").split("\n")) {
if (!line.startsWith("BEFORE\t")) continue;
const [, img, id, dg] = line.split("\t");
if (img) before[img] = { id: nz(id), digest: nz(dg) };
}
for (const line of section(raw, "===SU:DOCKER_INSPECT_AFTER===", "===SU:EXIT=").split("\n")) {
if (!line.startsWith("AFTER\t")) continue;
const [, img, id, dg, ver] = line.split("\t");
if (img) after[img] = { id: nz(id), digest: nz(dg), version: nz(ver) };
}
const pullSection = section(raw, "===SU:DOCKER_PULL===", "===SU:DOCKER_INSPECT_AFTER===");
const seen = new Set<string>();
const pullErrors: SnapshotError[] = [];
for (const line of pullSection.split("\n")) {
if (!/\b(error|unauthorized|denied|forbidden|failed to|no such host|connection refused|timeout)\b/i.test(line)) {
continue;
}
const message = cleanDockerError(line);
if (!message || seen.has(message)) continue;
seen.add(message);
const kind = /\b(unauthorized|authentication required|denied|forbidden|incorrect username)\b/i.test(line)
? "registry_auth_failed"
: "pull_failed";
pullErrors.push({ source: "docker", kind, severity: "error", message });
}
const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw);
const exitCode = exitMatch ? Number(exitMatch[1]) : null;
return { before, after, pullErrors, exitCode };
}
/** Compare BEFORE/AFTER et construit le résultat canonique (services + changes). */
export function buildDockerPullResult(stackName: string, raw: string): DockerPullResult {
const { before, after, pullErrors, exitCode } = parseDockerPullCheck(raw);
const services: DockerSnapshotService[] = [];
const changes: DockerImageChange[] = [];
const images = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).sort();
for (const img of images) {
const b = before[img];
const a = after[img];
const fromId = b?.id ?? null;
const toId = a?.id ?? null;
const fromDigest = b?.digest ?? null;
const toDigest = a?.digest ?? null;
const candidateVersion = a?.version ?? null;
const changed =
(!!toId && !!fromId && toId !== fromId) ||
(!!toDigest && !!fromDigest && toDigest !== fromDigest);
const status: NonNullable<DockerSnapshotService["status"]> = !a
? "error"
: changed
? "updates_available"
: "up_to_date";
services.push({
serviceName: img,
image: img,
currentImageId: fromId,
currentDigest: fromDigest,
candidateImageId: toId,
candidateDigest: toDigest,
currentVersion: null,
candidateVersion,
status,
});
if (changed) {
changes.push({
stack: stackName,
serviceName: img,
imageRef: img,
fromImageId: fromId,
toImageId: toId,
fromDigest,
toDigest,
operation: "pulled",
});
}
}
const hasFailure = pullErrors.length > 0 || (exitCode !== null && exitCode !== 0);
const status: SnapshotStatus = hasFailure
? "error"
: services.some((s) => s.status === "updates_available")
? "updates_available"
: "ok";
return { services, changes, errors: pullErrors, status, exitCode };
}
/** Empreinte de déduplication Docker : digests prioritaires, fallback image IDs. */
export function dockerDedupKey(
image: string,
fromDigest: string | null,
toDigest: string | null,
fromId?: string | null,
toId?: string | null,
): string {
if (fromDigest || toDigest) return `${image}|${fromDigest ?? ""}|${toDigest ?? ""}`;
return `${image}|${fromId ?? ""}|${toId ?? ""}`;
}
// ----------------------------------------------------------------------------
// Orchestration : pull-check d'un stack (SSH + persistance).
// ----------------------------------------------------------------------------
export interface PullCheckOutcome {
result: DockerPullResult;
raw: string;
stackName: string;
}
/** Lance `docker compose pull` sur un stack `enabled`, compare et persiste les services. */
export async function pullCheckStack(
machineId: string,
stackId: string,
onData?: (chunk: string) => void,
): Promise<PullCheckOutcome> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
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}) : pull-check refusé`);
}
const script = renderTemplate("docker/pull-check.sh.tpl", { stackDir: stack.workingDir });
const res = await runScriptSudo(
getCreds(m),
script,
(c) => {
onData?.(c);
outputHub.publish(machineId, c);
},
900000,
);
const raw = res.stdout;
const result = buildDockerPullResult(stack.name, raw);
// Persistance des services (upsert par stackId + serviceName).
const now = new Date().toISOString();
for (const s of result.services) {
const existing = db
.select()
.from(schema.dockerStackServices)
.where(
and(
eq(schema.dockerStackServices.stackId, stackId),
eq(schema.dockerStackServices.serviceName, s.serviceName),
),
)
.get();
const fields = {
imageRef: s.image,
currentImageId: s.currentImageId ?? null,
currentDigest: s.currentDigest ?? null,
candidateImageId: s.candidateImageId ?? null,
candidateDigest: s.candidateDigest ?? null,
versionLabel: s.candidateVersion ?? null,
status: s.status ?? null,
updatedAt: now,
};
if (existing) {
db.update(schema.dockerStackServices).set(fields).where(eq(schema.dockerStackServices.id, existing.id)).run();
} else {
db.insert(schema.dockerStackServices)
.values({ id: randomUUID(), stackId, serviceName: s.serviceName, ...fields })
.run();
}
}
db.update(schema.dockerComposeStacks)
.set({ lastUpdateAt: now, updatedAt: now })
.where(eq(schema.dockerComposeStacks.id, stackId))
.run();
db.update(schema.dockerSettings)
.set({ lastPullCheckAt: now, updatedAt: now })
.where(eq(schema.dockerSettings.machineId, machineId))
.run();
return { result, raw, stackName: stack.name };
}