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:
@@ -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`.
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}==="
|
||||
Reference in New Issue
Block a user