// 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; after: Record; 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. */ function cleanDockerError(line: string): string { return line .replace(/https?:\/\/\S+/gi, "") .replace(/\b(token|bearer|authorization|auth|password|passwd|secret|key)=\S+/gi, "$1=") .replace(/\bBearer\s+\S+/gi, "Bearer ") .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 = {}; const after: Record = {}; 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(); 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 = !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 { 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 }; }