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,
|
"when": 1780684150263,
|
||||||
"tag": "0004_thin_ted_forrester",
|
"tag": "0004_thin_ted_forrester",
|
||||||
"breakpoints": true
|
"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
|
status: text("status"), // up_to_date | updates_available | error
|
||||||
updatedAt: text("updated_at").notNull(),
|
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 { Hono } from "hono";
|
||||||
import { machinesRoutes } from "./machines.js";
|
import { machinesRoutes } from "./machines.js";
|
||||||
import { actionsRoutes } from "./actions.js";
|
import { actionsRoutes } from "./actions.js";
|
||||||
|
import { actionRequestsRoutes } from "./actionRequests.js";
|
||||||
import { getServerCapabilities } from "../services/capabilities.js";
|
import { getServerCapabilities } from "../services/capabilities.js";
|
||||||
import { getSystemMetrics, getSystemStatus } from "../services/system.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.get("/system/metrics", (c) => c.json(getSystemMetrics()));
|
||||||
api.route("/machines", machinesRoutes);
|
api.route("/machines", machinesRoutes);
|
||||||
api.route("/machines", actionsRoutes);
|
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);
|
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. */
|
/** 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
|
return line
|
||||||
.replace(/https?:\/\/\S+/gi, "<url>")
|
.replace(/https?:\/\/\S+/gi, "<url>")
|
||||||
.replace(/\b(token|bearer|authorization|auth|password|passwd|secret|key)=\S+/gi, "$1=<redacted>")
|
.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 {
|
export interface RunActionOpts {
|
||||||
stackId?: string;
|
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(
|
export async function runAction(
|
||||||
@@ -201,6 +269,70 @@ export async function runAction(
|
|||||||
return resultPull;
|
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 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);
|
||||||
|
|||||||
@@ -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