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>
254 lines
8.3 KiB
TypeScript
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 };
|
|
}
|