diff --git a/docs/superpowers/plans/2026-06-05-tache2-sj5-docker-pull-check.md b/docs/superpowers/plans/2026-06-05-tache2-sj5-docker-pull-check.md new file mode 100644 index 0000000..93e0f78 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-tache2-sj5-docker-pull-check.md @@ -0,0 +1,57 @@ +# Tâche 2 — SJ-5 : Docker pull-check + comparaison déterministe + +> Statut : **implémenté** (2026-06-05). tsc 0 erreur · 85 tests · build OK. +> Réf. design : `docs/design/tache2/20-docker.md §4.3`, `40-contrats-json.md §3/§6`, `80-sous-jalons.md` SJ-5. + +## Périmètre livré + +Télécharger les images candidates d'un stack Compose **sans démarrer de conteneur** +(`docker compose pull`), comparer avant/après par image ID + repo digest + label OCI, +et persister l'état des services — **sans toucher au flux jalon 1** et sans déclencher +de pull automatique (action manuelle par stack, non incluse dans `refreshMachine`). + +## Composants + +- **Template** `templates/docker/pull-check.sh.tpl` — délimiteurs Mustache `<% %>` + (`<%stackDir%>`), Go-templates `{{.Id}}` / `{{join .RepoDigests ","}}` préservés. + Sections `===SU:DOCKER_INSPECT_BEFORE/PULL/INSPECT_AFTER===` + `===SU:EXIT=N===`. +- **`server/services/dockerPull.ts`** : + - `parseDockerPullCheck(raw)` — lit BEFORE/AFTER (id, digest, version), code de sortie, + et extrait les erreurs de pull **nettoyées de tout secret** (URLs, token/bearer/password). + - `buildDockerPullResult(stackName, raw)` — comparaison déterministe → `services` + (`up_to_date | updates_available | error` par image) + `changes` (`operation:"pulled"` + uniquement pour les images modifiées) + `status` global + `errors`. + - `dockerDedupKey(image, fromDigest, toDigest, fromId?, toId?)` — empreinte fonctionnelle + (digests prioritaires, fallback image IDs), conforme `40 §6`. + - `pullCheckStack(machineId, stackId, onData?)` — orchestration SSH + upsert des services + dans `docker_stack_services` (par `stackId + serviceName`), maj `lastUpdateAt` du stack + et `lastPullCheckAt` des settings. **Refuse un stack non `enabled`.** +- **`server/services/dockerPull.test.ts`** — 7 cas (parse, nettoyage secret registry, + classement up_to_date/updates_available, change unique, status global, dédup). +- **Wiring** : + - `runAction(machineId, action, opts?: { stackId })` — branche dédiée `docker_pull_check` + (archivage report/log, `ExecutionResult.docker.pull.changes` + `dedupKey`, event). + - Chemin générique : injection `stackDir` quand `stackId` fourni → **corrige aussi + `docker_inspect_current`** (SJ-4 le déclarait sans orchestration par stack). + - `POST /:id/actions` — allowlist élargie aux actions Docker passives/non-applicatives + (`docker_scan`, `docker_inspect_current`, `docker_pull_check`) ; `stackId` requis pour + les actions par-stack. **Destructives (apply/down/prune agressif) toujours hors API** + jusqu'au socle `action_requests` (SJ-6). +- **`shared/types.ts`** : `DockerImageChange.dedupKey?` (additif, pour mutualisation Hermes). + +## Pas de migration + +Le schéma SJ-4 (`docker_stack_services` avec `current/candidate_image_id|digest`, +`version_label`, `status` ; `docker_settings.last_pull_check_at`) couvrait déjà SJ-5. + +## Sécurité + +- `docker compose pull` sans `up` → aucun conteneur recréé (pré-check pur applicatif). +- Erreurs registry (`registry_auth_failed` / `pull_failed`) **nettoyées** : ni URL, ni token, + ni mot de passe ne remontent vers UI/MCP (test dédié). +- Credentials registry (`~/.docker/config.json`) jamais lus ni renvoyés. + +## Reste pour SJ-6 + +`docker_compose_apply` (up -d --remove-orphans), `docker_prune_images`, `docker_compose_down`, +table `docker_image_events`, et validation UI explicite via `action_requests`. diff --git a/server/routes/actions.ts b/server/routes/actions.ts index ba858d7..33997d0 100644 --- a/server/routes/actions.ts +++ b/server/routes/actions.ts @@ -6,13 +6,30 @@ import type { ActionType } from "@shared/types.js"; export const actionsRoutes = new Hono(); +// Actions autorisées par l'API. Les actions destructives Docker +// (docker_compose_apply/down, docker_prune_images agressif) restent hors API +// jusqu'au socle de validation (action_requests, SJ-6). +const ALLOWED_ACTIONS: ActionType[] = [ + "apt_full_upgrade", + "reboot", + // Docker passifs / non-applicatifs (SJ-4/SJ-5). + "docker_scan", + "docker_inspect_current", + "docker_pull_check", +]; +// Actions Docker ciblant un stack précis : stackId obligatoire. +const NEED_STACK: ActionType[] = ["docker_inspect_current", "docker_pull_check"]; + actionsRoutes.post("/:id/actions", async (c) => { - const { action } = (await c.req.json()) as { action: ActionType }; - if (action !== "apt_full_upgrade" && action !== "reboot") { + const { action, stackId } = (await c.req.json()) as { action: ActionType; stackId?: string }; + if (!ALLOWED_ACTIONS.includes(action)) { return c.json({ error: "Action non autorisée" }, 400); } + if (NEED_STACK.includes(action) && !stackId) { + return c.json({ error: "stackId requis pour cette action" }, 400); + } // Exécution lancée en arrière-plan; le suivi se fait via WebSocket. - runAction(c.req.param("id"), action).catch((err) => + runAction(c.req.param("id"), action, stackId ? { stackId } : undefined).catch((err) => console.error("[action]", (err as Error).message), ); return c.json({ ok: true, action }, 202); diff --git a/server/services/dockerPull.test.ts b/server/services/dockerPull.test.ts new file mode 100644 index 0000000..5b846e0 --- /dev/null +++ b/server/services/dockerPull.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { parseDockerPullCheck, buildDockerPullResult, dockerDedupKey } from "./dockerPull.js"; + +// Stack à 3 images : nginx inchangé, app mis à jour, redis en échec d'auth registry. +const RAW = [ + "===SU:DOCKER_INSPECT_BEFORE===", + "BEFORE\tnginx:latest\tsha256:aaa\tnginx@sha256:d1", + "BEFORE\tmyorg/app:latest\tsha256:bbb\tmyorg/app@sha256:d2", + "BEFORE\tredis:7\tsha256:ccc\tredis@sha256:d3", + "===SU:DOCKER_PULL===", + "nginx Pulling ", + "nginx Pull complete ", + "myorg/app Pulling ", + "myorg/app Downloaded newer image ", + "redis Pulling ", + 'redis Error response from daemon: Head "https://registry-1.docker.io/v2/library/redis/manifests/7": unauthorized: incorrect username or password for token=AbCdEf123', + "===SU:DOCKER_INSPECT_AFTER===", + "AFTER\tnginx:latest\tsha256:aaa\tnginx@sha256:d1\t1.25.3", + "AFTER\tmyorg/app:latest\tsha256:zzz\tmyorg/app@sha256:d9\t2.4.0", + "AFTER\tredis:7\tsha256:ccc\tredis@sha256:d3\t7.2.0", + "===SU:EXIT=18===", +].join("\n"); + +describe("parseDockerPullCheck", () => { + it("lit les sections BEFORE / AFTER et le code de sortie", () => { + const p = parseDockerPullCheck(RAW); + expect(p.exitCode).toBe(18); + expect(p.before["myorg/app:latest"]).toEqual({ id: "sha256:bbb", digest: "myorg/app@sha256:d2" }); + expect(p.after["myorg/app:latest"]).toEqual({ + id: "sha256:zzz", + digest: "myorg/app@sha256:d9", + version: "2.4.0", + }); + }); + + it("détecte l'erreur d'authentification registry et la nettoie (pas d'URL ni de token)", () => { + const p = parseDockerPullCheck(RAW); + expect(p.pullErrors.length).toBeGreaterThan(0); + const auth = p.pullErrors.find((e) => e.kind === "registry_auth_failed"); + expect(auth).toBeTruthy(); + expect(auth!.message).not.toContain("registry-1.docker.io"); + expect(auth!.message).not.toContain("AbCdEf123"); + expect(auth!.message.toLowerCase()).toContain("unauthorized"); + }); +}); + +describe("buildDockerPullResult", () => { + it("classe up_to_date / updates_available par image et n'émet de change que pour les modifiées", () => { + const r = buildDockerPullResult("media", RAW); + const byImage = new Map(r.services.map((s) => [s.image, s])); + + expect(byImage.get("nginx:latest")!.status).toBe("up_to_date"); + const app = byImage.get("myorg/app:latest")!; + expect(app.status).toBe("updates_available"); + expect(app.currentImageId).toBe("sha256:bbb"); + expect(app.candidateImageId).toBe("sha256:zzz"); + expect(app.candidateVersion).toBe("2.4.0"); + + // Un seul change "pulled" (app). nginx inchangé, redis id inchangé. + expect(r.changes).toHaveLength(1); + expect(r.changes[0]).toMatchObject({ + stack: "media", + imageRef: "myorg/app:latest", + fromImageId: "sha256:bbb", + toImageId: "sha256:zzz", + operation: "pulled", + }); + }); + + it("status global = error quand le pull renvoie un code non nul / une erreur", () => { + const r = buildDockerPullResult("media", RAW); + expect(r.status).toBe("error"); + expect(r.errors.some((e) => e.source === "docker")).toBe(true); + }); + + it("status = updates_available sans erreur quand tout réussit", () => { + const ok = [ + "===SU:DOCKER_INSPECT_BEFORE===", + "BEFORE\tapp:latest\tsha256:old\tapp@sha256:o", + "===SU:DOCKER_PULL===", + "app Pulling ", + "app Downloaded newer image ", + "===SU:DOCKER_INSPECT_AFTER===", + "AFTER\tapp:latest\tsha256:new\tapp@sha256:n\t3.0", + "===SU:EXIT=0===", + ].join("\n"); + const r = buildDockerPullResult("s", ok); + expect(r.status).toBe("updates_available"); + expect(r.errors).toHaveLength(0); + }); +}); + +describe("dockerDedupKey", () => { + it("utilise les digests en priorité", () => { + expect(dockerDedupKey("app:latest", "app@sha256:d2", "app@sha256:d9")).toBe( + "app:latest|app@sha256:d2|app@sha256:d9", + ); + }); + + it("retombe sur les image IDs quand les digests manquent", () => { + expect(dockerDedupKey("app:latest", null, null, "sha256:bbb", "sha256:zzz")).toBe( + "app:latest|sha256:bbb|sha256:zzz", + ); + }); +}); diff --git a/server/services/dockerPull.ts b/server/services/dockerPull.ts new file mode 100644 index 0000000..71ae270 --- /dev/null +++ b/server/services/dockerPull.ts @@ -0,0 +1,253 @@ +// 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 }; +} diff --git a/server/services/execute.ts b/server/services/execute.ts index 6c6a768..a2343ba 100644 --- a/server/services/execute.ts +++ b/server/services/execute.ts @@ -30,7 +30,15 @@ const TEMPLATE_FOR: Partial> = { docker_inspect_current: "docker/inspect-compose.sh.tpl", }; -export async function runAction(machineId: string, action: ActionType): Promise { +export interface RunActionOpts { + stackId?: string; +} + +export async function runAction( + machineId: string, + action: ActionType, + opts?: RunActionOpts, +): Promise { const m = getMachineRow(machineId); if (!m) throw new Error("Machine introuvable"); @@ -112,10 +120,97 @@ export async function runAction(machineId: string, action: ActionType): Promise< return resultDocker; } + // --- SJ-5 : docker_pull_check délégué au service dédié (pull + comparaison + persistance) --- + if (action === "docker_pull_check") { + if (!opts?.stackId) throw new Error("docker_pull_check requiert un stackId"); + const { pullCheckStack, dockerDedupKey } = await import("./dockerPull.js"); + let rawPull = ""; + let pullStatus: ExecutionStatus = "ok"; + let importantPull: string[] = []; + let dockerExec: ExecutionResult["docker"]; + try { + const outcome = await pullCheckStack(machineId, opts.stackId, (c) => { + rawPull += c; + }); + rawPull = outcome.raw; + const r = outcome.result; + pullStatus = r.status === "error" ? "error" : r.status === "warning" ? "warning" : "ok"; + const changes = r.changes.map((ch) => ({ + ...ch, + dedupKey: dockerDedupKey(ch.imageRef ?? ch.stack, ch.fromDigest ?? null, ch.toDigest ?? null, ch.fromImageId ?? null, ch.toImageId ?? null), + })); + dockerExec = { pull: { changes, ...(r.errors.length ? { errors: r.errors } : {}) } }; + importantPull = [ + `docker_pull_check ${outcome.stackName} : ${r.changes.length} image(s) mise(s) à jour (${r.services.length} service(s))`, + ...r.services.map((s) => ` ${s.status} ${s.image}`), + ...r.errors.map((e) => ` [${e.kind}] ${e.message}`), + ]; + outputHub.publish(machineId, `\n===SU:DONE status=${pullStatus} changes=${r.changes.length}===\n`); + } catch (err) { + pullStatus = "error"; + importantPull = [`[ERREUR] ${(err as Error).message}`]; + rawPull += `\n[ERREUR] ${(err as Error).message}\n`; + outputHub.publish(machineId, `\n[ERREUR] ${(err as Error).message}\n`); + } + const finishedAtPull = new Date().toISOString(); + const dirPull = join(env.reportsDir, machineId); + mkdirSync(dirPull, { recursive: true }); + const rawLogPathPull = join(dirPull, `${executionId}.log`); + const reportPathPull = join(dirPull, `${executionId}.md`); + writeFileSync(rawLogPathPull, rawPull || importantPull.join("\n") + "\n", "utf8"); + const resultPull: ExecutionResult = { + executionId, machineId, startedAt, finishedAt: finishedAtPull, + mode: "manual", action, status: pullStatus, + rebootRequiredAfterRun: false, + importantLogLines: importantPull, + rawLogRef: rawLogPathPull, reportRef: reportPathPull, + ...(dockerExec ? { docker: dockerExec } : {}), + }; + writeFileSync(reportPathPull, buildReportMarkdown(resultPull, m.name), "utf8"); + const reportIdPull = randomUUID(); + db.update(schema.executions).set({ + finishedAt: finishedAtPull, status: pullStatus, schemaVersion: 1, + resultJson: JSON.stringify(resultPull), importantJson: JSON.stringify(importantPull), + reportPath: reportPathPull, rawLogPath: rawLogPathPull, reportId: reportIdPull, + exitCode: pullStatus === "ok" ? 0 : 1, + errorKind: pullStatus === "error" ? "execution_failed" : null, + errorMessage: pullStatus === "error" ? (importantPull.at(-1) ?? null) : null, + }).where(eq(schema.executions.id, executionId)).run(); + db.update(schema.machines).set({ status: pullStatus === "error" ? "error" : "unknown" }) + .where(eq(schema.machines.id, machineId)).run(); + db.insert(schema.reports).values({ + id: reportIdPull, machineId, executionId, kind: "machine", + title: `${m.name} — docker_pull_check`, path: reportPathPull, createdAt: finishedAtPull, + }).run(); + db.insert(schema.rawArtifacts).values({ + id: randomUUID(), machineId, kind: "raw_log", path: rawLogPathPull, + bytes: statSync(rawLogPathPull).size, createdAt: finishedAtPull, + retentionPolicy: pullStatus === "error" ? "failed" : "default", + }).run(); + upsertMachineState(machineId, { + status: pullStatus === "error" ? "error" : "unknown", + runningJobId: null, + lastErrorKind: pullStatus === "error" ? "execution_failed" : null, + lastErrorMessage: pullStatus === "error" ? (importantPull.at(-1) ?? null) : null, + }); + recordEvent({ + machineId, eventType: "action_docker_pull_check", + severity: pullStatus === "error" ? "error" : "info", + executionId, message: `Action docker_pull_check : ${pullStatus}`, + }); + return resultPull; + } + const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null; const rel = TEMPLATE_FOR[action]; if (!rel) throw new Error("Action sans template: " + action); - const script = renderTemplate(rel, { aptProxy: proxy }); + // Templates Docker par-stack (inspect) : injecter stackDir ; ignoré par les templates APT. + let stackDir: string | null = null; + if (opts?.stackId) { + const st = db.select().from(schema.dockerComposeStacks).where(eq(schema.dockerComposeStacks.id, opts.stackId)).get(); + stackDir = st?.workingDir ?? null; + } + const script = renderTemplate(rel, { aptProxy: proxy, stackDir }); const inactivity = action === "reboot" ? 0 : 600000; diff --git a/shared/types.ts b/shared/types.ts index 4404358..bc56a48 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -183,6 +183,7 @@ export interface DockerImageChange { fromDigest?: string | null; toDigest?: string | null; operation: "pulled" | "recreated" | "pruned"; + dedupKey?: string; // empreinte fonctionnelle (mutualisation Hermes) } export interface DockerExecutionResult { diff --git a/templates/docker/pull-check.sh.tpl b/templates/docker/pull-check.sh.tpl new file mode 100644 index 0000000..ba1a875 --- /dev/null +++ b/templates/docker/pull-check.sh.tpl @@ -0,0 +1,21 @@ +#!/bin/sh +export LC_ALL=C +cd "<%stackDir%>" || { echo "===SU:DOCKER_ERR==="; echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; } +echo "===SU:DOCKER_INSPECT_BEFORE===" +docker compose config --images 2>/dev/null | while IFS= read -r img; do + id=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || echo "") + dg=$(docker image inspect "$img" --format '{{join .RepoDigests ","}}' 2>/dev/null || echo "") + echo "BEFORE $img $id $dg" +done +echo "===SU:DOCKER_PULL===" +# Télécharge les images candidates SANS démarrer de conteneurs. +docker compose pull --policy always --ignore-buildable 2>&1 +CODE=$? +echo "===SU:DOCKER_INSPECT_AFTER===" +docker compose config --images 2>/dev/null | while IFS= read -r img; do + id=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || echo "") + dg=$(docker image inspect "$img" --format '{{join .RepoDigests ","}}' 2>/dev/null || echo "") + ver=$(docker image inspect "$img" --format '{{index .Config.Labels "org.opencontainers.image.version"}}' 2>/dev/null || echo "") + echo "AFTER $img $id $dg $ver" +done +echo "===SU:EXIT=${CODE}==="