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();
|
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) => {
|
actionsRoutes.post("/:id/actions", async (c) => {
|
||||||
const { action } = (await c.req.json()) as { action: ActionType };
|
const { action, stackId } = (await c.req.json()) as { action: ActionType; stackId?: string };
|
||||||
if (action !== "apt_full_upgrade" && action !== "reboot") {
|
if (!ALLOWED_ACTIONS.includes(action)) {
|
||||||
return c.json({ error: "Action non autorisée" }, 400);
|
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.
|
// 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),
|
console.error("[action]", (err as Error).message),
|
||||||
);
|
);
|
||||||
return c.json({ ok: true, action }, 202);
|
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",
|
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);
|
const m = getMachineRow(machineId);
|
||||||
if (!m) throw new Error("Machine introuvable");
|
if (!m) throw new Error("Machine introuvable");
|
||||||
|
|
||||||
@@ -112,10 +120,97 @@ export async function runAction(machineId: string, action: ActionType): Promise<
|
|||||||
return resultDocker;
|
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 proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
|
||||||
const rel = TEMPLATE_FOR[action];
|
const rel = TEMPLATE_FOR[action];
|
||||||
if (!rel) throw new Error("Action sans template: " + 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;
|
const inactivity = action === "reboot" ? 0 : 600000;
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export interface DockerImageChange {
|
|||||||
fromDigest?: string | null;
|
fromDigest?: string | null;
|
||||||
toDigest?: string | null;
|
toDigest?: string | null;
|
||||||
operation: "pulled" | "recreated" | "pruned";
|
operation: "pulled" | "recreated" | "pruned";
|
||||||
|
dedupKey?: string; // empreinte fonctionnelle (mutualisation Hermes)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DockerExecutionResult {
|
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