feat(docker): apply/prune/down + socle action_requests (tâche 2 SJ-6)

- migration 0005 : tables docker_image_events + action_requests
- templates apply-compose (up -d --remove-orphans), prune-images (safe/agressif),
  down-compose (sans volumes/rmi)
- dockerApply: parsers TDD (apply recreated/running/exited, prune images+bytes,
  down removed, parseHumanBytes) + orchestration applyStack/pruneImages/downStack
  réservée aux stacks enabled, insère docker_image_events
- actionRequests: create/approve/reject/list — actions destructives validées
  explicitement (Hermes propose, opérateur approuve, run en arrière-plan) ;
  hors API directe (POST /:id/actions reste passif uniquement)
- routes /machines/:id/action-requests + /action-requests/:id[/approve|/reject]
- execute: RunActionOpts.aggressive, branches apply/prune/down, helper
  archiveExecution mutualisant le boilerplate d'archivage

tsc 0 erreur · 91 tests · build OK · boot OK (migrations 0000→0005).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 06:05:59 +02:00
parent b1c81ba518
commit edb22a59c7
15 changed files with 3045 additions and 1 deletions
@@ -0,0 +1,45 @@
# Tâche 2 — SJ-6 : Docker apply / prune / down + socle action_requests
> Statut : **implémenté** (2026-06-06). tsc 0 erreur · 91 tests · build OK · boot OK (migrations 0000→0005).
> Réf. design : `docs/design/tache2/20-docker.md §4.4-4.6`, `40-contrats-json.md §4`, `70-securite.md §2`, `80-sous-jalons.md` SJ-6.
## Périmètre livré
Actions Docker **destructives** (recrée/supprime) protégées par un socle de
**validation explicite** (`action_requests`) : Hermes/UI proposent, l'opérateur approuve,
l'exécution part en arrière-plan. Aucune de ces actions n'est accessible directement
via `POST /:id/actions` (allowlist passive uniquement).
## Composants
- **Migration 0005** (`0005_silent_drax.sql`, timestamp monotone) : tables
`docker_image_events` (historique pulled/recreated/pruned + bytes) et
`action_requests` (pending|approved|rejected|executed|expired).
- **Templates** `docker/apply-compose.sh.tpl` (`up -d --remove-orphans`),
`docker/prune-images.sh.tpl` (safe par défaut / `<%#aggressive%>` = `-a --filter until=168h`),
`docker/down-compose.sh.tpl` (down simple, **`--volumes`/`--rmi` interdits**).
- **`server/services/dockerApply.ts`** :
- parsers purs (TDD) : `parseDockerApply` (recreated/running/exited via ps json),
`parseDockerPrune` (`imagesDeleted` + `Total reclaimed space` → octets),
`parseDockerDown` (removed), `parseHumanBytes` (unités décimales Docker).
- orchestration : `applyStack` / `pruneImages` / `downStack` — réservées aux stacks
`enabled`, insèrent les `docker_image_events`. Erreurs nettoyées (réutilise `cleanDockerError`).
- **`server/services/actionRequests.ts`** : `createActionRequest` (refuse une action non
destructive, exige `stackId` pour apply/down), `approve` (→ `runAction` en tâche de fond,
pose `executionId`/`executed`), `reject`, `get`, `list`.
- **Routes** `server/routes/actionRequests.ts` (montées à la racine `/api`) :
`POST /machines/:id/action-requests`, `GET …`, `GET/POST /action-requests/:id[/approve|/reject]`.
- **`execute.ts`** : `RunActionOpts.aggressive`, branches `docker_compose_apply` /
`docker_prune_images` / `docker_compose_down`, helper `archiveExecution` mutualisant
le boilerplate (log/rapport/DB/état/event) + `ExecutionResult.docker.up|prune`.
## Sécurité
- Destructives **hors API directe** : passent obligatoirement par un `action_request` approuvé.
- `down` sans volumes ni rmi (volumes préservés). Prune agressif = risque distinct (champ `aggressive`).
- Erreurs Docker nettoyées (URL/token/password) avant UI/MCP.
## Reste tâche 2
SJ-7 (profils Proxmox/RPi + proxy persistent), SJ-8/9 (post-install). UI des boutons
validés (Appliquer/Prune/Down) = tâche 3 (frontend, design system).
+34
View File
@@ -0,0 +1,34 @@
CREATE TABLE `action_requests` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`requested_by_type` text NOT NULL,
`requested_by_id` text,
`action` text NOT NULL,
`risk` text,
`status` text NOT NULL,
`summary` text,
`payload_json` text,
`created_at` text NOT NULL,
`approved_at` text,
`approved_by` text,
`execution_id` text,
`expires_at` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `docker_image_events` (
`id` text PRIMARY KEY NOT NULL,
`execution_id` text,
`machine_id` text NOT NULL,
`stack_id` text,
`service_name` text,
`image_ref` text,
`from_image_id` text,
`to_image_id` text,
`from_digest` text,
`to_digest` text,
`operation` text,
`bytes_reclaimed` integer,
`created_at` text NOT NULL,
FOREIGN KEY (`execution_id`) REFERENCES `executions`(`id`) ON UPDATE no action ON DELETE set null
);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -36,6 +36,13 @@
"when": 1780684150263,
"tag": "0004_thin_ted_forrester",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1780718324238,
"tag": "0005_silent_drax",
"breakpoints": true
}
]
}
+35
View File
@@ -277,3 +277,38 @@ export const dockerStackServices = sqliteTable("docker_stack_services", {
status: text("status"), // up_to_date | updates_available | error
updatedAt: text("updated_at").notNull(),
});
// SJ-6 : historique pull/apply/prune (tache1.9.md §8).
export const dockerImageEvents = sqliteTable("docker_image_events", {
id: text("id").primaryKey(),
executionId: text("execution_id").references(() => executions.id, { onDelete: "set null" }),
machineId: text("machine_id").notNull(),
stackId: text("stack_id"),
serviceName: text("service_name"),
imageRef: text("image_ref"),
fromImageId: text("from_image_id"),
toImageId: text("to_image_id"),
fromDigest: text("from_digest"),
toDigest: text("to_digest"),
operation: text("operation"), // pulled | recreated | pruned
bytesReclaimed: integer("bytes_reclaimed"),
createdAt: text("created_at").notNull(),
});
// SJ-6 : demandes d'actions destructives à valider (UI/Hermes) (tache1.9.md §10).
export const actionRequests = sqliteTable("action_requests", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
requestedByType: text("requested_by_type").notNull(), // user | hermes | schedule
requestedById: text("requested_by_id"),
action: text("action").notNull(),
risk: text("risk"),
status: text("status").notNull(), // pending | approved | rejected | executed | expired
summary: text("summary"),
payloadJson: text("payload_json"),
createdAt: text("created_at").notNull(),
approvedAt: text("approved_at"),
approvedBy: text("approved_by"),
executionId: text("execution_id"),
expiresAt: text("expires_at"),
});
+63
View File
@@ -0,0 +1,63 @@
// server/routes/actionRequests.ts
import { Hono } from "hono";
import {
createActionRequest,
getActionRequest,
listActionRequests,
approveActionRequest,
rejectActionRequest,
} from "../services/actionRequests.js";
import type { ActionType } from "@shared/types.js";
export const actionRequestsRoutes = new Hono();
// Crée une demande d'action destructive (pending). Hermes/UI proposent ; aucune exécution ici.
actionRequestsRoutes.post("/machines/:id/action-requests", async (c) => {
const body = (await c.req.json()) as {
action: ActionType;
stackId?: string;
aggressive?: boolean;
summary?: string;
requestedByType?: "user" | "hermes" | "schedule";
};
try {
const req = createActionRequest({
machineId: c.req.param("id"),
action: body.action,
requestedByType: body.requestedByType,
summary: body.summary,
payload: { stackId: body.stackId, aggressive: body.aggressive },
});
return c.json(req, 201);
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
actionRequestsRoutes.get("/machines/:id/action-requests", (c) =>
c.json(listActionRequests(c.req.param("id"))),
);
actionRequestsRoutes.get("/action-requests/:reqId", (c) => {
const req = getActionRequest(c.req.param("reqId"));
return req ? c.json(req) : c.json({ error: "Demande introuvable" }, 404);
});
// Validation opérateur → déclenche l'exécution en arrière-plan.
actionRequestsRoutes.post("/action-requests/:reqId/approve", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { approvedBy?: string };
try {
return c.json(approveActionRequest(c.req.param("reqId"), body.approvedBy), 202);
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
actionRequestsRoutes.post("/action-requests/:reqId/reject", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { by?: string };
try {
return c.json(rejectActionRequest(c.req.param("reqId"), body.by));
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
+2
View File
@@ -2,6 +2,7 @@
import { Hono } from "hono";
import { machinesRoutes } from "./machines.js";
import { actionsRoutes } from "./actions.js";
import { actionRequestsRoutes } from "./actionRequests.js";
import { getServerCapabilities } from "../services/capabilities.js";
import { getSystemMetrics, getSystemStatus } from "../services/system.js";
@@ -11,3 +12,4 @@ api.get("/system/status", (c) => c.json(getSystemStatus()));
api.get("/system/metrics", (c) => c.json(getSystemMetrics()));
api.route("/machines", machinesRoutes);
api.route("/machines", actionsRoutes);
api.route("/", actionRequestsRoutes);
+118
View File
@@ -0,0 +1,118 @@
// server/services/actionRequests.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import { runAction, type RunActionOpts } from "./execute.js";
import { recordEvent } from "./machineState.js";
import type { ActionType } from "@shared/types.js";
// Actions destructives nécessitant une validation explicite (70-securite.md §2).
export const DESTRUCTIVE_ACTIONS: Partial<Record<ActionType, "medium" | "high">> = {
docker_compose_apply: "medium",
docker_prune_images: "medium",
docker_compose_down: "high",
apt_full_upgrade: "medium",
apt_dist_upgrade: "medium",
apt_autoremove: "medium",
reboot: "high",
reboot_verified: "high",
};
const NEED_STACK: ActionType[] = ["docker_compose_apply", "docker_compose_down"];
export interface CreateRequestInput {
machineId: string;
action: ActionType;
requestedByType?: "user" | "hermes" | "schedule";
requestedById?: string | null;
summary?: string | null;
payload?: { stackId?: string; aggressive?: boolean } | null;
}
export function createActionRequest(input: CreateRequestInput) {
const risk = DESTRUCTIVE_ACTIONS[input.action];
if (!risk) throw new Error(`Action non destructive ou inconnue : ${input.action}`);
if (NEED_STACK.includes(input.action) && !input.payload?.stackId) {
throw new Error("stackId requis pour cette action");
}
const id = randomUUID();
const now = new Date().toISOString();
db.insert(schema.actionRequests).values({
id,
machineId: input.machineId,
requestedByType: input.requestedByType ?? "user",
requestedById: input.requestedById ?? null,
action: input.action,
risk,
status: "pending",
summary: input.summary ?? `Demande ${input.action}`,
payloadJson: input.payload ? JSON.stringify(input.payload) : null,
createdAt: now,
}).run();
recordEvent({
machineId: input.machineId,
eventType: "action_request_created",
severity: "info",
message: `Demande ${input.action} (risque ${risk}) en attente de validation`,
});
return getActionRequest(id);
}
export function getActionRequest(id: string) {
return db.select().from(schema.actionRequests).where(eq(schema.actionRequests.id, id)).get();
}
export function listActionRequests(machineId?: string) {
const q = db.select().from(schema.actionRequests);
const rows = machineId
? q.where(eq(schema.actionRequests.machineId, machineId)).all()
: q.all();
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export function rejectActionRequest(id: string, by?: string) {
const req = getActionRequest(id);
if (!req) throw new Error("Demande introuvable");
if (req.status !== "pending") throw new Error(`Demande déjà ${req.status}`);
db.update(schema.actionRequests)
.set({ status: "rejected", approvedAt: new Date().toISOString(), approvedBy: by ?? null })
.where(eq(schema.actionRequests.id, id))
.run();
return getActionRequest(id);
}
/**
* Approuve une demande et déclenche l'action en arrière-plan. Renvoie immédiatement
* la demande passée à `approved` ; `executionId`/`executed` sont posés à la fin du run.
*/
export function approveActionRequest(id: string, approvedBy?: string) {
const req = getActionRequest(id);
if (!req) throw new Error("Demande introuvable");
if (req.status !== "pending") throw new Error(`Demande déjà ${req.status}`);
if (!req.machineId) throw new Error("Demande sans machine");
const now = new Date().toISOString();
db.update(schema.actionRequests)
.set({ status: "approved", approvedAt: now, approvedBy: approvedBy ?? null })
.where(eq(schema.actionRequests.id, id))
.run();
const payload = req.payloadJson ? (JSON.parse(req.payloadJson) as { stackId?: string; aggressive?: boolean }) : {};
const opts: RunActionOpts = { stackId: payload.stackId, aggressive: payload.aggressive };
const machineId = req.machineId;
runAction(machineId, req.action as ActionType, opts)
.then((result) => {
db.update(schema.actionRequests)
.set({ status: "executed", executionId: result.executionId })
.where(eq(schema.actionRequests.id, id))
.run();
})
.catch((err) => {
recordEvent({
machineId,
eventType: "action_request_failed",
severity: "error",
message: `Demande ${req.action} échouée : ${(err as Error).message}`,
});
});
return getActionRequest(id);
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import {
parseDockerApply,
parseDockerPrune,
parseDockerDown,
parseHumanBytes,
} from "./dockerApply.js";
describe("parseDockerApply", () => {
const RAW = [
"===SU:DOCKER_APPLY===",
" Container media-app-1 Recreate",
" Container media-app-1 Recreated",
" Container media-worker-1 Created",
" Container media-db-1 Running",
" Container media-app-1 Started",
"===SU:DOCKER_PS_AFTER===",
'{"Name":"media-app-1","Service":"app","State":"running","Health":""}',
'{"Name":"media-db-1","Service":"db","State":"running","Health":"healthy"}',
'{"Name":"media-worker-1","Service":"worker","State":"exited","Health":""}',
"===SU:DOCKER_INSPECT_AFTER===",
"IMG\tsha256:newapp\tapp@sha256:dapp",
"IMG\tsha256:db\tdb@sha256:ddb",
"===SU:EXIT=0===",
].join("\n");
it("liste les conteneurs recréés/créés et l'état running/exited", () => {
const r = parseDockerApply(RAW);
expect(r.recreated.sort()).toEqual(["media-app-1", "media-worker-1"]);
expect(r.running.sort()).toEqual(["media-app-1", "media-db-1"]);
expect(r.exited).toEqual(["media-worker-1"]);
expect(r.errors).toHaveLength(0);
expect(r.exitCode).toBe(0);
});
it("remonte une erreur d'application nettoyée", () => {
const bad = [
"===SU:DOCKER_APPLY===",
' Container app-1 Error pulling image from https://reg.example/v2 token=SECRET123',
"===SU:DOCKER_PS_AFTER===",
"===SU:DOCKER_INSPECT_AFTER===",
"===SU:EXIT=1===",
].join("\n");
const r = parseDockerApply(bad);
expect(r.errors.length).toBeGreaterThan(0);
expect(r.errors[0]!.message).not.toContain("reg.example");
expect(r.errors[0]!.message).not.toContain("SECRET123");
expect(r.exitCode).toBe(1);
});
});
describe("parseHumanBytes", () => {
it("convertit les unités décimales Docker", () => {
expect(parseHumanBytes("0B")).toBe(0);
expect(parseHumanBytes("512MB")).toBe(512_000_000);
expect(parseHumanBytes("1.234GB")).toBe(Math.round(1.234 * 1e9));
expect(parseHumanBytes("1.5kB")).toBe(1500);
});
it("renvoie 0 pour une entrée illisible", () => {
expect(parseHumanBytes("n/a")).toBe(0);
});
});
describe("parseDockerPrune", () => {
const RAW = [
"===SU:DOCKER_PRUNE===",
"Deleted Images:",
"untagged: redis:6",
"deleted: sha256:aaa",
"deleted: sha256:bbb",
"",
"Total reclaimed space: 1.234GB",
"===SU:EXIT=0===",
].join("\n");
it("liste les images supprimées et l'espace récupéré", () => {
const r = parseDockerPrune(RAW);
expect(r.imagesDeleted).toEqual(["sha256:aaa", "sha256:bbb"]);
expect(r.bytesReclaimed).toBe(Math.round(1.234 * 1e9));
expect(r.errors).toHaveLength(0);
});
});
describe("parseDockerDown", () => {
it("liste les conteneurs retirés", () => {
const RAW = [
"===SU:DOCKER_DOWN===",
" Container media-app-1 Stopping",
" Container media-app-1 Stopped",
" Container media-app-1 Removing",
" Container media-app-1 Removed",
" Network media_default Removed",
"===SU:EXIT=0===",
].join("\n");
const r = parseDockerDown(RAW);
expect(r.removed).toEqual(["media-app-1"]);
expect(r.errors).toHaveLength(0);
});
});
+289
View File
@@ -0,0 +1,289 @@
// server/services/dockerApply.ts
import { randomUUID } from "node:crypto";
import { 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 { cleanDockerError } from "./dockerPull.js";
import type { SnapshotError } from "@shared/types.js";
// ----------------------------------------------------------------------------
// Fonctions pures (testables).
// ----------------------------------------------------------------------------
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);
}
const ERROR_RE = /\b(error|unauthorized|denied|forbidden|failed|no such host|connection refused|timeout|cannot)\b/i;
function collectErrors(text: string, kind: string): SnapshotError[] {
const seen = new Set<string>();
const out: SnapshotError[] = [];
for (const line of text.split("\n")) {
if (!ERROR_RE.test(line)) continue;
const message = cleanDockerError(line);
if (!message || seen.has(message)) continue;
seen.add(message);
out.push({ source: "docker", kind, severity: "error", message });
}
return out;
}
function exitOf(raw: string): number | null {
const m = /===SU:EXIT=(\d+)===/.exec(raw);
return m ? Number(m[1]) : null;
}
export interface DockerApplyParsed {
recreated: string[];
running: string[];
exited: string[];
imagesAfter: { id: string | null; digests: string | null }[];
errors: SnapshotError[];
exitCode: number | null;
}
export function parseDockerApply(raw: string): DockerApplyParsed {
const applySec = section(raw, "===SU:DOCKER_APPLY===", "===SU:DOCKER_PS_AFTER===");
const psSec = section(raw, "===SU:DOCKER_PS_AFTER===", "===SU:DOCKER_INSPECT_AFTER===");
const inspectSec = section(raw, "===SU:DOCKER_INSPECT_AFTER===", "===SU:EXIT=");
const recreated = new Set<string>();
for (const m of applySec.matchAll(/Container\s+(\S+)\s+(Recreated|Created)\s*$/gm)) {
if (m[1]) recreated.add(m[1]);
}
const running: string[] = [];
const exited: string[] = [];
const psLines = psSec.trim();
const records: { Name?: string; State?: string }[] = [];
if (psLines.startsWith("[")) {
try {
records.push(...(JSON.parse(psLines) as typeof records));
} catch {
/* ignore */
}
} else {
for (const line of psLines.split("\n")) {
const t = line.trim();
if (!t.startsWith("{")) continue;
try {
records.push(JSON.parse(t));
} catch {
/* ignore */
}
}
}
for (const r of records) {
if (!r.Name) continue;
if (r.State === "running") running.push(r.Name);
else if (r.State === "exited") exited.push(r.Name);
}
const imagesAfter: DockerApplyParsed["imagesAfter"] = [];
for (const line of inspectSec.split("\n")) {
if (!line.startsWith("IMG\t")) continue;
const parts = line.split("\t");
imagesAfter.push({ id: parts[1] || null, digests: parts[2] || null });
}
return {
recreated: [...recreated],
running,
exited,
imagesAfter,
errors: collectErrors(applySec, "compose_apply_failed"),
exitCode: exitOf(raw),
};
}
/** Convertit une taille humaine Docker (décimale) en octets. */
export function parseHumanBytes(s: string): number {
const m = /([\d.]+)\s*([kKMGTP]?i?B)/.exec(s.trim());
if (!m) return 0;
const value = Number(m[1]);
if (!Number.isFinite(value)) return 0;
const unit = (m[2] ?? "B").toUpperCase();
const mult: Record<string, number> = {
B: 1,
KB: 1e3,
MB: 1e6,
GB: 1e9,
TB: 1e12,
PB: 1e15,
};
return Math.round(value * (mult[unit] ?? 1));
}
export interface DockerPruneParsed {
imagesDeleted: string[];
bytesReclaimed: number;
errors: SnapshotError[];
exitCode: number | null;
}
export function parseDockerPrune(raw: string): DockerPruneParsed {
const sec = section(raw, "===SU:DOCKER_PRUNE===", "===SU:EXIT=");
const imagesDeleted: string[] = [];
let bytesReclaimed = 0;
for (const line of sec.split("\n")) {
const del = /^deleted:\s+(\S+)/.exec(line.trim());
if (del?.[1]) imagesDeleted.push(del[1]);
const total = /Total reclaimed space:\s*(.+)$/.exec(line);
if (total?.[1]) bytesReclaimed = parseHumanBytes(total[1]);
}
return { imagesDeleted, bytesReclaimed, errors: collectErrors(sec, "prune_failed"), exitCode: exitOf(raw) };
}
export interface DockerDownParsed {
removed: string[];
errors: SnapshotError[];
exitCode: number | null;
}
export function parseDockerDown(raw: string): DockerDownParsed {
const sec = section(raw, "===SU:DOCKER_DOWN===", "===SU:EXIT=");
const removed = new Set<string>();
for (const m of sec.matchAll(/Container\s+(\S+)\s+Removed\s*$/gm)) {
if (m[1]) removed.add(m[1]);
}
return { removed: [...removed], errors: collectErrors(sec, "compose_down_failed"), exitCode: exitOf(raw) };
}
// ----------------------------------------------------------------------------
// Orchestration (SSH). Réservé aux stacks `enabled` ; déclenché via action_requests.
// ----------------------------------------------------------------------------
function getEnabledStack(machineId: string, stackId: string) {
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})`);
return stack;
}
async function runDockerScript(
machineId: string,
rel: string,
vars: Record<string, unknown>,
onData?: (c: string) => void,
): Promise<string> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
const script = renderTemplate(rel, vars);
const res = await runScriptSudo(
getCreds(m),
script,
(c) => {
onData?.(c);
outputHub.publish(machineId, c);
},
900000,
);
return res.stdout;
}
export interface ApplyOutcome {
parsed: DockerApplyParsed;
raw: string;
stackName: string;
events: typeof schema.dockerImageEvents.$inferInsert[];
}
/** `docker compose up -d --remove-orphans` sur un stack enabled + persistance des events. */
export async function applyStack(
machineId: string,
stackId: string,
executionId: string,
onData?: (c: string) => void,
): Promise<ApplyOutcome> {
const stack = getEnabledStack(machineId, stackId);
const raw = await runDockerScript(machineId, "docker/apply-compose.sh.tpl", { stackDir: stack.workingDir }, onData);
const parsed = parseDockerApply(raw);
const now = new Date().toISOString();
const events = parsed.recreated.map((name) => ({
id: randomUUID(),
executionId,
machineId,
stackId,
serviceName: name,
imageRef: null,
fromImageId: null,
toImageId: null,
fromDigest: null,
toDigest: null,
operation: "recreated",
bytesReclaimed: null,
createdAt: now,
}));
for (const ev of events) db.insert(schema.dockerImageEvents).values(ev).run();
db.update(schema.dockerComposeStacks)
.set({ lastUpdateAt: now, updatedAt: now })
.where(eq(schema.dockerComposeStacks.id, stackId))
.run();
return { parsed, raw, stackName: stack.name, events };
}
export interface PruneOutcome {
parsed: DockerPruneParsed;
raw: string;
}
/** `docker image prune` (safe par défaut, agressif si demandé) + event pruned. */
export async function pruneImages(
machineId: string,
executionId: string,
aggressive: boolean,
onData?: (c: string) => void,
): Promise<PruneOutcome> {
const raw = await runDockerScript(machineId, "docker/prune-images.sh.tpl", { aggressive }, onData);
const parsed = parseDockerPrune(raw);
if (parsed.imagesDeleted.length > 0 || parsed.bytesReclaimed > 0) {
db.insert(schema.dockerImageEvents)
.values({
id: randomUUID(),
executionId,
machineId,
stackId: null,
serviceName: null,
imageRef: null,
fromImageId: null,
toImageId: null,
fromDigest: null,
toDigest: null,
operation: "pruned",
bytesReclaimed: parsed.bytesReclaimed,
createdAt: new Date().toISOString(),
})
.run();
}
return { parsed, raw };
}
export interface DownOutcome {
parsed: DockerDownParsed;
raw: string;
stackName: string;
}
/** `docker compose down` (sans volumes/rmi) sur un stack enabled. */
export async function downStack(
machineId: string,
stackId: string,
onData?: (c: string) => void,
): Promise<DownOutcome> {
const stack = getEnabledStack(machineId, stackId);
const raw = await runDockerScript(machineId, "docker/down-compose.sh.tpl", { stackDir: stack.workingDir }, onData);
const parsed = parseDockerDown(raw);
return { parsed, raw, stackName: stack.name };
}
+1 -1
View File
@@ -41,7 +41,7 @@ export interface DockerPullResult {
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 {
export function cleanDockerError(line: string): string {
return line
.replace(/https?:\/\/\S+/gi, "<url>")
.replace(/\b(token|bearer|authorization|auth|password|passwd|secret|key)=\S+/gi, "$1=<redacted>")
+132
View File
@@ -32,6 +32,74 @@ const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
export interface RunActionOpts {
stackId?: string;
aggressive?: boolean; // docker_prune_images
}
/**
* Archive une exécution terminée (log brut + rapport + lignes DB + état machine +
* event) et renvoie l'ExecutionResult. Mutualise le boilerplate des branches Docker.
*/
function archiveExecution(args: {
machineId: string;
machineName: string;
executionId: string;
action: ActionType;
startedAt: string;
status: ExecutionStatus;
raw: string;
importantLines: string[];
docker?: ExecutionResult["docker"];
errors?: ExecutionResult["errors"];
}): ExecutionResult {
const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args;
const finishedAt = new Date().toISOString();
const dir = join(env.reportsDir, machineId);
mkdirSync(dir, { recursive: true });
const rawLogPath = join(dir, `${executionId}.log`);
const reportPath = join(dir, `${executionId}.md`);
writeFileSync(rawLogPath, raw || importantLines.join("\n") + "\n", "utf8");
const result: ExecutionResult = {
executionId, machineId, startedAt, finishedAt, mode: "manual", action, status,
rebootRequiredAfterRun: false,
importantLogLines: importantLines,
rawLogRef: rawLogPath, reportRef: reportPath,
...(args.docker ? { docker: args.docker } : {}),
...(args.errors && args.errors.length ? { errors: args.errors } : {}),
};
writeFileSync(reportPath, buildReportMarkdown(result, machineName), "utf8");
const reportId = randomUUID();
db.update(schema.executions).set({
finishedAt, status, schemaVersion: 1,
resultJson: JSON.stringify(result), importantJson: JSON.stringify(importantLines),
reportPath, rawLogPath, reportId,
exitCode: status === "ok" ? 0 : 1,
errorKind: status === "error" ? "execution_failed" : null,
errorMessage: status === "error" ? (importantLines.at(-1) ?? null) : null,
}).where(eq(schema.executions.id, executionId)).run();
db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" })
.where(eq(schema.machines.id, machineId)).run();
db.insert(schema.reports).values({
id: reportId, machineId, executionId, kind: "machine",
title: `${machineName}${action}`, path: reportPath, createdAt: finishedAt,
}).run();
db.insert(schema.rawArtifacts).values({
id: randomUUID(), machineId, kind: "raw_log", path: rawLogPath,
bytes: statSync(rawLogPath).size, createdAt: finishedAt,
retentionPolicy: status === "error" ? "failed" : "default",
}).run();
upsertMachineState(machineId, {
status: status === "error" ? "error" : "unknown",
runningJobId: null,
lastErrorKind: status === "error" ? "execution_failed" : null,
lastErrorMessage: status === "error" ? (importantLines.at(-1) ?? null) : null,
});
recordEvent({
machineId, eventType: `action_${action}`,
severity: status === "error" ? "error" : "info",
executionId, message: `Action ${action} : ${status}`,
});
outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`);
return result;
}
export async function runAction(
@@ -201,6 +269,70 @@ export async function runAction(
return resultPull;
}
// --- SJ-6 : actions Docker destructives (apply / prune / down) ---
if (action === "docker_compose_apply") {
if (!opts?.stackId) throw new Error("docker_compose_apply requiert un stackId");
const { applyStack } = await import("./dockerApply.js");
try {
const o = await applyStack(machineId, opts.stackId, executionId, (c) => outputHub.publish(machineId, c));
const p = o.parsed;
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
const important = [
`docker_compose_apply ${o.stackName} : ${p.recreated.length} recréé(s), ${p.running.length} running, ${p.exited.length} exited`,
...p.recreated.map((n) => ` recreated ${n}`),
...p.exited.map((n) => ` exited ${n}`),
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
];
return archiveExecution({
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
importantLines: important, docker: { up: { recreated: p.recreated, running: p.running, exited: p.exited, ...(p.errors.length ? { errors: p.errors } : {}) } },
});
} catch (err) {
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
}
}
if (action === "docker_prune_images") {
const { pruneImages } = await import("./dockerApply.js");
try {
const o = await pruneImages(machineId, executionId, !!opts?.aggressive, (c) => outputHub.publish(machineId, c));
const p = o.parsed;
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
const mb = (p.bytesReclaimed / 1e6).toFixed(1);
const important = [
`docker_prune_images (${opts?.aggressive ? "agressif" : "safe"}) : ${p.imagesDeleted.length} image(s), ${mb} Mo récupérés`,
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
];
return archiveExecution({
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
importantLines: important, docker: { prune: { imagesDeleted: p.imagesDeleted, bytesReclaimed: p.bytesReclaimed, ...(p.errors.length ? { errors: p.errors } : {}) } },
});
} catch (err) {
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
}
}
if (action === "docker_compose_down") {
if (!opts?.stackId) throw new Error("docker_compose_down requiert un stackId");
const { downStack } = await import("./dockerApply.js");
try {
const o = await downStack(machineId, opts.stackId, (c) => outputHub.publish(machineId, c));
const p = o.parsed;
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
const important = [
`docker_compose_down ${o.stackName} : ${p.removed.length} conteneur(s) retiré(s)`,
...p.removed.map((n) => ` removed ${n}`),
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
];
return archiveExecution({
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
importantLines: important, errors: p.errors,
});
} catch (err) {
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
}
}
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const rel = TEMPLATE_FOR[action];
if (!rel) throw new Error("Action sans template: " + action);
+13
View File
@@ -0,0 +1,13 @@
#!/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_APPLY==="
docker compose up -d --remove-orphans 2>&1
CODE=$?
echo "===SU:DOCKER_PS_AFTER==="
docker compose ps --format json 2>&1
echo "===SU:DOCKER_INSPECT_AFTER==="
docker compose config --images 2>/dev/null | while IFS= read -r img; do
docker image inspect "$img" --format 'IMG\t{{.Id}}\t{{join .RepoDigests ","}}' 2>/dev/null || echo "IMG_MISSING\t$img"
done
echo "===SU:EXIT=${CODE}==="
+8
View File
@@ -0,0 +1,8 @@
#!/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_DOWN==="
# --volumes et --rmi INTERDITS au MVP : down simple uniquement (préserve les volumes).
docker compose down 2>&1
CODE=$?
echo "===SU:EXIT=${CODE}==="
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
export LC_ALL=C
echo "===SU:DOCKER_PRUNE==="
<%#aggressive%>
# Mode agressif : supprime TOUTES les images non référencées (>168h). Validation UI distincte.
docker image prune -a -f --filter "until=168h" 2>&1
<%/aggressive%>
<%^aggressive%>
# Mode sûr par défaut : images dangling uniquement.
docker image prune -f 2>&1
<%/aggressive%>
CODE=$?
echo "===SU:EXIT=${CODE}==="