feat(docker): pull-check + comparaison déterministe par stack (tâche 2 SJ-5)

- template docker/pull-check.sh.tpl (pull sans up, inspect before/after)
- dockerPull: parseDockerPullCheck + buildDockerPullResult (TDD) — compare
  image id/digest/label OCI → services up_to_date|updates_available|error,
  changes operation=pulled ; erreurs registry nettoyées (URL/token/password)
- dockerDedupKey (digests prioritaires, fallback image ids) + DockerImageChange.dedupKey
- pullCheckStack: SSH + upsert docker_stack_services, refuse stack non enabled,
  refresh Docker séparé (hors refreshMachine, pas de pull auto)
- execute: runAction(opts.stackId), branche docker_pull_check, injection stackDir
  (corrige docker_inspect_current) ; route: allowlist Docker passifs + pull_check,
  destructives toujours hors API jusqu'à action_requests (SJ-6)

Pas de migration (schéma SJ-4 suffisant). tsc 0 erreur · 85 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:02:38 +02:00
parent 2af8e74079
commit b1c81ba518
7 changed files with 554 additions and 5 deletions
@@ -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`.
+20 -3
View File
@@ -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);
+105
View File
@@ -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",
);
});
});
+253
View File
@@ -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<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. */
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 };
}
+97 -2
View File
@@ -30,7 +30,15 @@ const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
docker_inspect_current: "docker/inspect-compose.sh.tpl",
};
export async function runAction(machineId: string, action: ActionType): Promise<ExecutionResult> {
export interface RunActionOpts {
stackId?: string;
}
export async function runAction(
machineId: string,
action: ActionType,
opts?: RunActionOpts,
): Promise<ExecutionResult> {
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;
+1
View File
@@ -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 {
+21
View File
@@ -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}==="