edb22a59c7
- 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>
119 lines
4.4 KiB
TypeScript
119 lines
4.4 KiB
TypeScript
// 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);
|
|
}
|