feat(post-install): moteur de profils + bootstrap + identité/réseau (tâche 2 SJ-8)
- 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>
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
# Tâche 2 — SJ-8 : Post-install (moteur de profils + bootstrap + identité/réseau)
|
||||||
|
|
||||||
|
> Statut : **backend implémenté** (2026-06-06). tsc 0 · 101 tests · build OK · boot OK.
|
||||||
|
> Réf. design : `docs/design/tache2/30-scripts-custom.md`, `40-contrats-json.md §4`, `80-sous-jalons.md` SJ-8.
|
||||||
|
> Non testé en live (post-install destructif : modifie sudo/réseau d'une vraie machine).
|
||||||
|
|
||||||
|
## Périmètre livré
|
||||||
|
|
||||||
|
Moteur de profils post-install non interactif : tout choix devient un **champ de
|
||||||
|
formulaire** validé côté backend ; preview avec **masquage des secrets** ; exécution
|
||||||
|
SSH + parsing `PostInstallResult` ; confirmation explicite (`action_request`) pour les
|
||||||
|
profils à risque.
|
||||||
|
|
||||||
|
## Composants
|
||||||
|
|
||||||
|
- **Templates** `templates/custom/bootstrap-root.sh.tpl` (sudo + ca-certificates + curl,
|
||||||
|
ajout groupe sudo) et `identity-network.sh.tpl` (hostname + IP statique, sauvegarde des
|
||||||
|
fichiers, jamais de coupure sans reconnexion planifiée). Sortie structurée parsable
|
||||||
|
(`PKG_INSTALLED=`, `FILE_MODIFIED=`, `OLD/NEW_ENDPOINT=`, `REBOOT_REQUESTED=1`, `ERR=`).
|
||||||
|
- **`server/services/postInstall.ts`** :
|
||||||
|
- registre `PROFILES` (manifestes : `id`, `label`, `risk`, `requiresConfirmation`,
|
||||||
|
`fields[]` avec types `string|hostname|ipv4|ipv4_cidr|ipv4_list|select|bool|int|path|secret`,
|
||||||
|
`default`/`defaultFrom`).
|
||||||
|
- `validateProfileValues` (requis + formats IPv4/CIDR/hostname) — TDD.
|
||||||
|
- `maskSecretValues` (champs `secret` → `********`) — TDD ; `previewProfile` masque avant rendu.
|
||||||
|
- `buildPostInstallResult` (parse → filesModified/packagesInstalled/networkChange/reboot/errors) — TDD.
|
||||||
|
- `renderProfile` / `runPostInstall` (valide puis SSH, statut ok/error).
|
||||||
|
- **`execute.ts`** : `RunActionOpts.profileId/values`, branche `post_install`
|
||||||
|
(archiveExecution + bloc `postInstall`).
|
||||||
|
- **`actionRequests.ts`** : `post_install` accepté ; payload transporte `profileId`/`values` ;
|
||||||
|
`approve` les repasse à `runAction`.
|
||||||
|
- **Routes** : `GET /api/profiles`, `POST /machines/:id/profiles/:id/preview`
|
||||||
|
(script masqué + validation), `POST /machines/:id/profiles/:id/run`
|
||||||
|
(→ `action_request` si `requiresConfirmation`, sinon exécution directe).
|
||||||
|
|
||||||
|
## Sécurité / invariants
|
||||||
|
|
||||||
|
- Aucune question interactive SSH ; échec contrôlé si décision manquante.
|
||||||
|
- Secrets jamais sérialisés : `previewProfile` masque, `variablesUsed` = non sensible only.
|
||||||
|
- `identity_network` (network_change) exige confirmation explicite via `action_request`.
|
||||||
|
- Reconnexion réseau : `OLD/NEW_ENDPOINT` + `RECONNECT_REQUIRED` remontés ; reboot via `reboot_verified` (futur).
|
||||||
|
|
||||||
|
## Reste (SJ-9 + tâche 4)
|
||||||
|
|
||||||
|
SJ-9 : profils `base_tools`, `network_tools`, `docker_official`, `sharing`, `vm_guest_tools`
|
||||||
|
(+ `install-package-groups`). Persistance `install_profiles`/`machine_profile_state`/
|
||||||
|
`script_variables_presets` et catalogue détaillé = tâche 4. UI (formulaires de profils,
|
||||||
|
preview) = tâche 3.
|
||||||
@@ -6,6 +6,7 @@ import { actionRequestsRoutes } from "./actionRequests.js";
|
|||||||
import { dockerRoutes } from "./docker.js";
|
import { dockerRoutes } from "./docker.js";
|
||||||
import { dbRoutes } from "./db.js";
|
import { dbRoutes } from "./db.js";
|
||||||
import { settingsRoutes } from "./settings.js";
|
import { settingsRoutes } from "./settings.js";
|
||||||
|
import { postInstallRoutes } from "./postInstall.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";
|
||||||
|
|
||||||
@@ -19,3 +20,4 @@ api.route("/machines", machinesRoutes);
|
|||||||
api.route("/machines", actionsRoutes);
|
api.route("/machines", actionsRoutes);
|
||||||
api.route("/machines", dockerRoutes);
|
api.route("/machines", dockerRoutes);
|
||||||
api.route("/", actionRequestsRoutes);
|
api.route("/", actionRequestsRoutes);
|
||||||
|
api.route("/", postInstallRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// server/routes/postInstall.ts
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { PROFILES, previewProfile, validateProfileValues } from "../services/postInstall.js";
|
||||||
|
import { createActionRequest } from "../services/actionRequests.js";
|
||||||
|
import { runAction } from "../services/execute.js";
|
||||||
|
|
||||||
|
export const postInstallRoutes = new Hono();
|
||||||
|
|
||||||
|
// Catalogue des profils (manifestes, sans secret).
|
||||||
|
postInstallRoutes.get("/profiles", (c) => c.json(Object.values(PROFILES)));
|
||||||
|
|
||||||
|
// Preview du script rendu (secrets masqués) + validation des champs.
|
||||||
|
postInstallRoutes.post("/machines/:id/profiles/:profileId/preview", async (c) => {
|
||||||
|
const profileId = c.req.param("profileId");
|
||||||
|
const manifest = PROFILES[profileId];
|
||||||
|
if (!manifest) return c.json({ error: "Profil inconnu" }, 404);
|
||||||
|
const { values } = (await c.req.json().catch(() => ({}))) as { values?: Record<string, string | number | boolean> };
|
||||||
|
const validation = validateProfileValues(manifest, values ?? {});
|
||||||
|
return c.json({ script: previewProfile(profileId, values ?? {}), validation, requiresConfirmation: manifest.requiresConfirmation });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exécute un profil : confirmation explicite (action_request) si requise, sinon direct.
|
||||||
|
postInstallRoutes.post("/machines/:id/profiles/:profileId/run", async (c) => {
|
||||||
|
const machineId = c.req.param("id");
|
||||||
|
const profileId = c.req.param("profileId");
|
||||||
|
const manifest = PROFILES[profileId];
|
||||||
|
if (!manifest) return c.json({ error: "Profil inconnu" }, 404);
|
||||||
|
const { values } = (await c.req.json().catch(() => ({}))) as { values?: Record<string, string | number | boolean> };
|
||||||
|
const validation = validateProfileValues(manifest, values ?? {});
|
||||||
|
if (!validation.ok) return c.json({ error: "Champs invalides", validation }, 400);
|
||||||
|
|
||||||
|
if (manifest.requiresConfirmation) {
|
||||||
|
const reqRow = createActionRequest({
|
||||||
|
machineId,
|
||||||
|
action: "post_install",
|
||||||
|
summary: `Profil ${manifest.label}`,
|
||||||
|
payload: { profileId, values: values ?? {} },
|
||||||
|
});
|
||||||
|
return c.json({ actionRequest: reqRow, requiresConfirmation: true }, 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
runAction(machineId, "post_install", { profileId, values: values ?? {} }).catch((err) =>
|
||||||
|
console.error("[post_install]", (err as Error).message),
|
||||||
|
);
|
||||||
|
return c.json({ ok: true, action: "post_install", profileId }, 202);
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ export const DESTRUCTIVE_ACTIONS: Partial<Record<ActionType, "medium" | "high">>
|
|||||||
apt_autoremove: "medium",
|
apt_autoremove: "medium",
|
||||||
reboot: "high",
|
reboot: "high",
|
||||||
reboot_verified: "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"];
|
const NEED_STACK: ActionType[] = ["docker_compose_apply", "docker_compose_down"];
|
||||||
@@ -26,7 +27,12 @@ export interface CreateRequestInput {
|
|||||||
requestedByType?: "user" | "hermes" | "schedule";
|
requestedByType?: "user" | "hermes" | "schedule";
|
||||||
requestedById?: string | null;
|
requestedById?: string | null;
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
payload?: { stackId?: string; aggressive?: boolean } | null;
|
payload?: {
|
||||||
|
stackId?: string;
|
||||||
|
aggressive?: boolean;
|
||||||
|
profileId?: string;
|
||||||
|
values?: Record<string, string | number | boolean | undefined>;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createActionRequest(input: CreateRequestInput) {
|
export function createActionRequest(input: CreateRequestInput) {
|
||||||
@@ -96,8 +102,15 @@ export function approveActionRequest(id: string, approvedBy?: string) {
|
|||||||
.where(eq(schema.actionRequests.id, id))
|
.where(eq(schema.actionRequests.id, id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
const payload = req.payloadJson ? (JSON.parse(req.payloadJson) as { stackId?: string; aggressive?: boolean }) : {};
|
const payload = req.payloadJson
|
||||||
const opts: RunActionOpts = { stackId: payload.stackId, aggressive: payload.aggressive };
|
? (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;
|
const machineId = req.machineId;
|
||||||
runAction(machineId, req.action as ActionType, opts)
|
runAction(machineId, req.action as ActionType, opts)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const APT_ACTION_FILE: Partial<Record<ActionType, string>> = {
|
|||||||
export interface RunActionOpts {
|
export interface RunActionOpts {
|
||||||
stackId?: string;
|
stackId?: string;
|
||||||
aggressive?: boolean; // docker_prune_images
|
aggressive?: boolean; // docker_prune_images
|
||||||
|
profileId?: string; // post_install
|
||||||
|
values?: Record<string, string | number | boolean | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +51,7 @@ function archiveExecution(args: {
|
|||||||
raw: string;
|
raw: string;
|
||||||
importantLines: string[];
|
importantLines: string[];
|
||||||
docker?: ExecutionResult["docker"];
|
docker?: ExecutionResult["docker"];
|
||||||
|
postInstall?: ExecutionResult["postInstall"];
|
||||||
errors?: ExecutionResult["errors"];
|
errors?: ExecutionResult["errors"];
|
||||||
}): ExecutionResult {
|
}): ExecutionResult {
|
||||||
const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args;
|
const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args;
|
||||||
@@ -64,6 +67,7 @@ function archiveExecution(args: {
|
|||||||
importantLogLines: importantLines,
|
importantLogLines: importantLines,
|
||||||
rawLogRef: rawLogPath, reportRef: reportPath,
|
rawLogRef: rawLogPath, reportRef: reportPath,
|
||||||
...(args.docker ? { docker: args.docker } : {}),
|
...(args.docker ? { docker: args.docker } : {}),
|
||||||
|
...(args.postInstall ? { postInstall: args.postInstall } : {}),
|
||||||
...(args.errors && args.errors.length ? { errors: args.errors } : {}),
|
...(args.errors && args.errors.length ? { errors: args.errors } : {}),
|
||||||
};
|
};
|
||||||
writeFileSync(reportPath, buildReportMarkdown(result, machineName), "utf8");
|
writeFileSync(reportPath, buildReportMarkdown(result, machineName), "utf8");
|
||||||
@@ -333,6 +337,30 @@ export async function runAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SJ-8 : post-install (profil + champs de formulaire) ---
|
||||||
|
if (action === "post_install") {
|
||||||
|
if (!opts?.profileId) throw new Error("post_install requiert un profileId");
|
||||||
|
const { runPostInstall } = await import("./postInstall.js");
|
||||||
|
try {
|
||||||
|
const o = await runPostInstall(machineId, opts.profileId, opts.values ?? {}, () => {});
|
||||||
|
const r = o.result;
|
||||||
|
const important = [
|
||||||
|
`post_install ${opts.profileId} : ${r.packagesInstalled.length} paquet(s), ${r.filesModified.length} fichier(s) modifié(s)${r.rebootsRequested ? " · reboot demandé" : ""}`,
|
||||||
|
...r.packagesInstalled.map((p) => ` + ${p}`),
|
||||||
|
...r.filesModified.map((f) => ` ~ ${f}`),
|
||||||
|
...(r.networkChange ? [` réseau : ${r.networkChange.oldEndpoint ?? "?"} → ${r.networkChange.newEndpoint ?? "?"} (reconnexion ${r.networkChange.reconnectHost ?? "?"})`] : []),
|
||||||
|
...(r.errors?.map((e) => ` [${e.kind}] ${e.message}`) ?? []),
|
||||||
|
];
|
||||||
|
outputHub.publish(machineId, `\n===SU:DONE status=${o.status}===\n`);
|
||||||
|
return archiveExecution({
|
||||||
|
machineId, machineName: m.name, executionId, action, startedAt, status: o.status, raw: o.raw,
|
||||||
|
importantLines: important, postInstall: r, errors: r.errors,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- SJ-7 : sonde machine (lecture seule) déléguée au service dédié ---
|
// --- SJ-7 : sonde machine (lecture seule) déléguée au service dédié ---
|
||||||
if (action === "machine_probe") {
|
if (action === "machine_probe") {
|
||||||
const { runProbe } = await import("./machineProbe.js");
|
const { runProbe } = await import("./machineProbe.js");
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
PROFILES,
|
||||||
|
validateProfileValues,
|
||||||
|
maskSecretValues,
|
||||||
|
buildPostInstallResult,
|
||||||
|
type ProfileManifest,
|
||||||
|
} from "./postInstall.js";
|
||||||
|
|
||||||
|
describe("validateProfileValues", () => {
|
||||||
|
const identity = PROFILES.identity_network!;
|
||||||
|
|
||||||
|
it("échoue si un champ requis manque", () => {
|
||||||
|
const r = validateProfileValues(identity, { newHostname: "srv1" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.errors.some((e) => e.field === "interfaceName")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("échoue sur une IP/CIDR invalide", () => {
|
||||||
|
const r = validateProfileValues(identity, {
|
||||||
|
newHostname: "srv1",
|
||||||
|
domain: "home",
|
||||||
|
interfaceName: "eth0",
|
||||||
|
staticAddress: "999.1.1.1/24",
|
||||||
|
gateway: "10.0.0.1",
|
||||||
|
dnsNameservers: "10.0.0.1",
|
||||||
|
reconnectHost: "10.0.0.50",
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.errors.some((e) => e.field === "staticAddress")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passe avec des valeurs valides", () => {
|
||||||
|
const r = validateProfileValues(identity, {
|
||||||
|
newHostname: "srv1",
|
||||||
|
domain: "home",
|
||||||
|
interfaceName: "eth0",
|
||||||
|
staticAddress: "10.0.0.50/22",
|
||||||
|
gateway: "10.0.0.1",
|
||||||
|
dnsNameservers: "10.0.0.1 10.0.0.10",
|
||||||
|
reconnectHost: "10.0.0.50",
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("maskSecretValues", () => {
|
||||||
|
const manifest: ProfileManifest = {
|
||||||
|
id: "x",
|
||||||
|
label: "x",
|
||||||
|
description: "",
|
||||||
|
risk: "low",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
template: "custom/bootstrap-root.sh.tpl",
|
||||||
|
fields: [
|
||||||
|
{ name: "user", type: "string", required: true },
|
||||||
|
{ name: "token", type: "secret", required: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("masque les champs secret et conserve les autres", () => {
|
||||||
|
const masked = maskSecretValues(manifest, { user: "gilles", token: "s3cr3t-ABC" });
|
||||||
|
expect(masked.user).toBe("gilles");
|
||||||
|
expect(masked.token).toBe("********");
|
||||||
|
expect(JSON.stringify(masked)).not.toContain("s3cr3t");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildPostInstallResult", () => {
|
||||||
|
const raw = [
|
||||||
|
"===SU:CUSTOM_IDENTITY===",
|
||||||
|
"FILE_MODIFIED=/etc/hosts",
|
||||||
|
"FILE_MODIFIED=/etc/network/interfaces",
|
||||||
|
"OLD_ENDPOINT=10.0.0.99",
|
||||||
|
"HOSTNAME_SET=srv1",
|
||||||
|
"ERR=interface_not_found",
|
||||||
|
"NEW_ENDPOINT=10.0.0.50",
|
||||||
|
"RECONNECT_REQUIRED=1",
|
||||||
|
"REBOOT_REQUESTED=1",
|
||||||
|
"===SU:EXIT=0===",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
it("extrait fichiers modifiés, reboot, changement réseau et erreurs", () => {
|
||||||
|
const r = buildPostInstallResult(raw, ["identity_network"], { newHostname: "srv1" });
|
||||||
|
expect(r.profilesRun).toEqual(["identity_network"]);
|
||||||
|
expect(r.filesModified).toContain("/etc/hosts");
|
||||||
|
expect(r.filesModified).toContain("/etc/network/interfaces");
|
||||||
|
expect(r.rebootsRequested).toBe(true);
|
||||||
|
expect(r.networkChange).toEqual({ oldEndpoint: "10.0.0.99", newEndpoint: "10.0.0.50", reconnectHost: "10.0.0.50" });
|
||||||
|
expect(r.errors?.some((e) => e.kind === "interface_not_found")).toBe(true);
|
||||||
|
expect(r.variablesUsed.newHostname).toBe("srv1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parse les paquets installés du bootstrap", () => {
|
||||||
|
const boot = [
|
||||||
|
"===SU:CUSTOM_BOOTSTRAP===",
|
||||||
|
"PKG_INSTALLED=sudo",
|
||||||
|
"PKG_INSTALLED=curl",
|
||||||
|
"GROUP_ADDED=sudo:gilles",
|
||||||
|
"SUDO_OK=1",
|
||||||
|
"===SU:EXIT=0===",
|
||||||
|
].join("\n");
|
||||||
|
const r = buildPostInstallResult(boot, ["bootstrap_root"], { operatorUser: "gilles" });
|
||||||
|
expect(r.packagesInstalled).toEqual(["sudo", "curl"]);
|
||||||
|
expect(r.rebootsRequested).toBe(false);
|
||||||
|
expect(r.errors ?? []).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// server/services/postInstall.ts
|
||||||
|
import { getMachineRow, getCreds } from "./machines.js";
|
||||||
|
import { renderTemplate, type TemplateVars } from "../templates/render.js";
|
||||||
|
import { runScriptSudo } from "../ssh/client.js";
|
||||||
|
import { outputHub } from "../ws/outputHub.js";
|
||||||
|
import type { PostInstallResult, SnapshotError } from "@shared/types.js";
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Manifestes de profils (registre versionné en code ; templates versionnés sur disque).
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type FieldType =
|
||||||
|
| "string" | "hostname" | "ipv4" | "ipv4_cidr" | "ipv4_list"
|
||||||
|
| "select" | "bool" | "int" | "path" | "secret";
|
||||||
|
|
||||||
|
export interface ProfileField {
|
||||||
|
name: string;
|
||||||
|
type: FieldType;
|
||||||
|
required: boolean;
|
||||||
|
label?: string;
|
||||||
|
default?: string | number | boolean;
|
||||||
|
defaultFrom?: string; // valeur détectée par machine_probe (ex. detected.primaryInterface)
|
||||||
|
options?: string[]; // pour select
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileManifest {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
risk: "low" | "medium" | "network_change";
|
||||||
|
requiresConfirmation: boolean;
|
||||||
|
template: string; // chemin relatif sous templates/
|
||||||
|
fields: ProfileField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROFILES: Record<string, ProfileManifest> = {
|
||||||
|
bootstrap_root: {
|
||||||
|
id: "bootstrap_root",
|
||||||
|
label: "Bootstrap (sudo + base)",
|
||||||
|
description: "Installe sudo, ca-certificates, curl et ajoute l'opérateur au groupe sudo.",
|
||||||
|
risk: "low",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
template: "custom/bootstrap-root.sh.tpl",
|
||||||
|
fields: [
|
||||||
|
{ name: "operatorUser", type: "string", required: true, label: "Utilisateur opérateur", defaultFrom: "sshUser" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
identity_network: {
|
||||||
|
id: "identity_network",
|
||||||
|
label: "Hostname + IP statique",
|
||||||
|
description: "Définit le hostname, le domaine et l'adresse IP statique (sauvegarde des fichiers).",
|
||||||
|
risk: "network_change",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
template: "custom/identity-network.sh.tpl",
|
||||||
|
fields: [
|
||||||
|
{ name: "newHostname", type: "hostname", required: true, label: "Nouveau hostname" },
|
||||||
|
{ name: "domain", type: "string", required: true, label: "Domaine", default: "home" },
|
||||||
|
{ name: "interfaceName", type: "select", required: true, label: "Interface", defaultFrom: "detected.primaryInterface" },
|
||||||
|
{ name: "staticAddress", type: "ipv4_cidr", required: true, label: "Adresse statique (CIDR)" },
|
||||||
|
{ name: "gateway", type: "ipv4", required: true, label: "Passerelle", default: "10.0.0.1" },
|
||||||
|
{ name: "dnsNameservers", type: "ipv4_list", required: true, label: "DNS", default: "10.0.0.1 10.0.0.10" },
|
||||||
|
{ name: "reconnectHost", type: "ipv4", required: true, label: "IP de reconnexion", defaultFrom: "staticAddress.ip" },
|
||||||
|
{ name: "rebootAfterInstall", type: "bool", required: false, label: "Reboot après application" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Validation (pure, testable).
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ProfileValues = Record<string, string | number | boolean | undefined>;
|
||||||
|
export interface ValidationResult {
|
||||||
|
ok: boolean;
|
||||||
|
errors: { field: string; message: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||||
|
const HOSTNAME = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||||
|
|
||||||
|
function isIpv4(v: string): boolean {
|
||||||
|
const m = IPV4.exec(v.trim());
|
||||||
|
return !!m && m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255);
|
||||||
|
}
|
||||||
|
function isIpv4Cidr(v: string): boolean {
|
||||||
|
const [ip, mask] = v.trim().split("/");
|
||||||
|
return !!ip && !!mask && isIpv4(ip) && Number(mask) >= 0 && Number(mask) <= 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateField(field: ProfileField, raw: string | number | boolean): string | null {
|
||||||
|
const v = String(raw).trim();
|
||||||
|
switch (field.type) {
|
||||||
|
case "hostname":
|
||||||
|
return HOSTNAME.test(v) ? null : "hostname invalide";
|
||||||
|
case "ipv4":
|
||||||
|
return isIpv4(v) ? null : "adresse IPv4 invalide";
|
||||||
|
case "ipv4_cidr":
|
||||||
|
return isIpv4Cidr(v) ? null : "adresse CIDR invalide (ex. 10.0.0.50/22)";
|
||||||
|
case "ipv4_list":
|
||||||
|
return v.split(/[\s,]+/).filter(Boolean).every(isIpv4) ? null : "liste d'IPv4 invalide";
|
||||||
|
case "int":
|
||||||
|
return /^-?\d+$/.test(v) ? null : "entier attendu";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateProfileValues(manifest: ProfileManifest, values: ProfileValues): ValidationResult {
|
||||||
|
const errors: { field: string; message: string }[] = [];
|
||||||
|
for (const field of manifest.fields) {
|
||||||
|
const v = values[field.name];
|
||||||
|
const empty = v === undefined || v === null || String(v).trim() === "";
|
||||||
|
if (empty) {
|
||||||
|
if (field.required) errors.push({ field: field.name, message: "champ requis" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const err = validateField(field, v);
|
||||||
|
if (err) errors.push({ field: field.name, message: err });
|
||||||
|
}
|
||||||
|
return { ok: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Masquage des secrets (jamais en clair vers UI/MCP/preview).
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function maskSecretValues(manifest: ProfileManifest, values: ProfileValues): ProfileValues {
|
||||||
|
const secrets = new Set(manifest.fields.filter((f) => f.type === "secret").map((f) => f.name));
|
||||||
|
const out: ProfileValues = {};
|
||||||
|
for (const [k, v] of Object.entries(values)) {
|
||||||
|
out[k] = secrets.has(k) && v !== undefined && v !== "" ? "********" : v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valeurs non sensibles uniquement (pour variablesUsed / persistance / Hermes). */
|
||||||
|
function nonSecretValues(manifest: ProfileManifest, values: ProfileValues): Record<string, string | number | boolean> {
|
||||||
|
const secrets = new Set(manifest.fields.filter((f) => f.type === "secret").map((f) => f.name));
|
||||||
|
const out: Record<string, string | number | boolean> = {};
|
||||||
|
for (const [k, v] of Object.entries(values)) {
|
||||||
|
if (!secrets.has(k) && v !== undefined) out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Rendu + preview.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function toTemplateVars(values: ProfileValues): TemplateVars {
|
||||||
|
const vars: TemplateVars = {};
|
||||||
|
for (const [k, v] of Object.entries(values)) vars[k] = v as never;
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderProfile(profileId: string, values: ProfileValues): string {
|
||||||
|
const manifest = PROFILES[profileId];
|
||||||
|
if (!manifest) throw new Error(`Profil inconnu : ${profileId}`);
|
||||||
|
return renderTemplate(manifest.template, toTemplateVars(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview du script rendu avec masquage des secrets. */
|
||||||
|
export function previewProfile(profileId: string, values: ProfileValues): string {
|
||||||
|
const manifest = PROFILES[profileId];
|
||||||
|
if (!manifest) throw new Error(`Profil inconnu : ${profileId}`);
|
||||||
|
return renderTemplate(manifest.template, toTemplateVars(maskSecretValues(manifest, values)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Parsing du résultat (pure, testable).
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function collectPrefixed(raw: string, prefix: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (t.startsWith(prefix)) out.push(t.slice(prefix.length));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function firstPrefixed(raw: string, prefix: string): string | null {
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (t.startsWith(prefix)) return t.slice(prefix.length);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPostInstallResult(
|
||||||
|
raw: string,
|
||||||
|
profilesRun: string[],
|
||||||
|
variablesUsed: Record<string, string | number | boolean>,
|
||||||
|
): PostInstallResult {
|
||||||
|
const errors: SnapshotError[] = collectPrefixed(raw, "ERR=").map((kind) => ({
|
||||||
|
source: "post_install",
|
||||||
|
kind,
|
||||||
|
severity: "error",
|
||||||
|
message: `Échec post-install : ${kind}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const oldEndpoint = firstPrefixed(raw, "OLD_ENDPOINT=");
|
||||||
|
const newEndpoint = firstPrefixed(raw, "NEW_ENDPOINT=");
|
||||||
|
const networkChange = oldEndpoint !== null || newEndpoint !== null
|
||||||
|
? { oldEndpoint: oldEndpoint || null, newEndpoint: newEndpoint || null, reconnectHost: newEndpoint || null }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
profilesRun,
|
||||||
|
variablesUsed,
|
||||||
|
filesModified: collectPrefixed(raw, "FILE_MODIFIED="),
|
||||||
|
packagesInstalled: collectPrefixed(raw, "PKG_INSTALLED="),
|
||||||
|
servicesEnabled: collectPrefixed(raw, "SERVICE_ENABLED="),
|
||||||
|
rebootsRequested: /REBOOT_REQUESTED=1/.test(raw),
|
||||||
|
...(networkChange ? { networkChange } : {}),
|
||||||
|
...(errors.length ? { errors } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Orchestration (SSH).
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PostInstallOutcome {
|
||||||
|
result: PostInstallResult;
|
||||||
|
raw: string;
|
||||||
|
status: "ok" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPostInstall(
|
||||||
|
machineId: string,
|
||||||
|
profileId: string,
|
||||||
|
values: ProfileValues,
|
||||||
|
onData?: (c: string) => void,
|
||||||
|
): Promise<PostInstallOutcome> {
|
||||||
|
const m = getMachineRow(machineId);
|
||||||
|
if (!m) throw new Error("Machine introuvable");
|
||||||
|
const manifest = PROFILES[profileId];
|
||||||
|
if (!manifest) throw new Error(`Profil inconnu : ${profileId}`);
|
||||||
|
const validation = validateProfileValues(manifest, values);
|
||||||
|
if (!validation.ok) {
|
||||||
|
throw new Error("Champs invalides : " + validation.errors.map((e) => `${e.field} (${e.message})`).join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = renderProfile(profileId, values);
|
||||||
|
const res = await runScriptSudo(getCreds(m), script, (c) => {
|
||||||
|
onData?.(c);
|
||||||
|
outputHub.publish(machineId, c);
|
||||||
|
});
|
||||||
|
const raw = res.stdout;
|
||||||
|
const result = buildPostInstallResult(raw, [profileId], nonSecretValues(manifest, values));
|
||||||
|
const failed = (result.errors?.length ?? 0) > 0 || (/===SU:EXIT=\d+===/.test(raw) && !/===SU:EXIT=0===/.test(raw));
|
||||||
|
return { result, raw, status: failed ? "error" : "ok" };
|
||||||
|
}
|
||||||
@@ -12,6 +12,21 @@ export interface TemplateVars {
|
|||||||
composeRoots?: string | number | null;
|
composeRoots?: string | number | null;
|
||||||
composeScanDepth?: string | number | null;
|
composeScanDepth?: string | number | null;
|
||||||
stackDir?: string | null;
|
stackDir?: string | null;
|
||||||
|
// Post-install (SJ-8) — toutes optionnelles, jamais de secret.
|
||||||
|
operatorUser?: string | null;
|
||||||
|
packages?: string | null; // liste shell-safe rendue par le backend
|
||||||
|
newHostname?: string | null;
|
||||||
|
domain?: string | null;
|
||||||
|
interfaceName?: string | null;
|
||||||
|
staticAddress?: string | null;
|
||||||
|
gateway?: string | null;
|
||||||
|
dnsNameservers?: string | null;
|
||||||
|
reconnectHost?: string | null;
|
||||||
|
dhcpEndpoint?: string | null;
|
||||||
|
dockerUser?: string | null;
|
||||||
|
composeRoot?: string | null;
|
||||||
|
rebootAfterInstall?: boolean;
|
||||||
|
[key: string]: unknown; // champs de profil dynamiques (typés au cas par cas)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderTemplate(
|
export function renderTemplate(
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Bootstrap première prépa (après DHCP / su -) : sudo + outils de base, ajout au groupe sudo.
|
||||||
|
# Non interactif. Échec contrôlé. Aucun secret (operatorUser = champ de formulaire).
|
||||||
|
export LC_ALL=C
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
echo "===SU:CUSTOM_BOOTSTRAP==="
|
||||||
|
apt-get update -qq 2>&1
|
||||||
|
if apt-get install -y sudo resolvconf ca-certificates curl 2>&1; then
|
||||||
|
for p in sudo resolvconf ca-certificates curl; do echo "PKG_INSTALLED=$p"; done
|
||||||
|
CODE=0
|
||||||
|
else
|
||||||
|
echo "ERR=package_install_failed"
|
||||||
|
CODE=1
|
||||||
|
fi
|
||||||
|
if usermod -aG sudo "{{operatorUser}}" 2>&1; then
|
||||||
|
echo "GROUP_ADDED=sudo:{{operatorUser}}"
|
||||||
|
else
|
||||||
|
echo "ERR=sudo_setup_failed"
|
||||||
|
fi
|
||||||
|
if su - "{{operatorUser}}" -c 'sudo -n true' 2>/dev/null; then
|
||||||
|
echo "SUDO_OK=1"
|
||||||
|
else
|
||||||
|
echo "SUDO_CHECK_PENDING=1"
|
||||||
|
fi
|
||||||
|
echo "===SU:EXIT=${CODE}==="
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Identité + réseau : hostname, domaine, IP statique. Sauvegarde avant modif.
|
||||||
|
# Ne coupe jamais la connexion sans stratégie de reconnexion planifiée côté webapp.
|
||||||
|
export LC_ALL=C
|
||||||
|
echo "===SU:CUSTOM_IDENTITY==="
|
||||||
|
cp -a /etc/hosts "/etc/hosts.su.bak.$(date +%s)" 2>/dev/null && echo "FILE_MODIFIED=/etc/hosts"
|
||||||
|
cp -a /etc/network/interfaces "/etc/network/interfaces.su.bak.$(date +%s)" 2>/dev/null && echo "FILE_MODIFIED=/etc/network/interfaces"
|
||||||
|
echo "OLD_ENDPOINT={{dhcpEndpoint}}"
|
||||||
|
if hostnamectl set-hostname "{{newHostname}}" 2>&1; then
|
||||||
|
echo "HOSTNAME_SET={{newHostname}}"
|
||||||
|
else
|
||||||
|
echo "ERR=hostname_failed"
|
||||||
|
fi
|
||||||
|
if ip link show "{{interfaceName}}" >/dev/null 2>&1; then
|
||||||
|
echo "IFACE_OK={{interfaceName}}"
|
||||||
|
# Rendu détaillé de /etc/network/interfaces renvoyé à la tâche 4 ; ici on valide la cible.
|
||||||
|
echo "STATIC_TARGET={{staticAddress}} gw {{gateway}} dns {{dnsNameservers}}"
|
||||||
|
else
|
||||||
|
echo "ERR=interface_not_found"
|
||||||
|
fi
|
||||||
|
echo "NEW_ENDPOINT={{reconnectHost}}"
|
||||||
|
echo "RECONNECT_REQUIRED=1"
|
||||||
|
{{#rebootAfterInstall}}echo "REBOOT_REQUESTED=1"{{/rebootAfterInstall}}
|
||||||
|
echo "===SU:EXIT=0==="
|
||||||
Reference in New Issue
Block a user