e6f4ae470b
- templates custom/bootstrap-root + identity-network (sortie structurée parsable, sauvegardes, échec contrôlé, jamais de coupure réseau sans reconnexion) - postInstall: registre de manifestes (champs typés + defaults/defaultFrom), validateProfileValues + maskSecretValues + buildPostInstallResult (TDD), renderProfile/previewProfile (masquage secrets), runPostInstall (SSH) - execute: RunActionOpts.profileId/values + branche post_install (bloc postInstall) - action_requests: post_install accepté, payload profileId/values transmis à approve - routes: GET /profiles, POST .../preview (script masqué + validation), POST .../run (action_request si requiresConfirmation, sinon direct) Champs = formulaire (pas de question SSH interactive) ; secrets jamais sérialisés ; identity_network exige confirmation. tsc 0 · 101 tests · build OK · boot OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
132 lines
4.7 KiB
TypeScript
132 lines
4.7 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",
|
|
post_install: "high", // risque réel porté par le manifeste du profil
|
|
};
|
|
|
|
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;
|
|
profileId?: string;
|
|
values?: Record<string, string | number | boolean | undefined>;
|
|
} | 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; profileId?: string; values?: Record<string, string | number | boolean | undefined> })
|
|
: {};
|
|
const opts: RunActionOpts = {
|
|
stackId: payload.stackId,
|
|
aggressive: payload.aggressive,
|
|
profileId: payload.profileId,
|
|
values: payload.values,
|
|
};
|
|
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);
|
|
}
|