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