3b16fdd52a
- mécanisme presetVars (variables fixes injectées au rendu, surchargées par le formulaire) - 6 profils : base_tools / network_tools / dev_git (listes de paquets, low), docker_official (dépôt officiel Debian, confirmation), sharing (Samba/NFS/mDNS, confirmation), vm_guest_tools (qemu/vmware) - 4 templates custom (install-package-groups, docker-official-debian, sharing, vm-guest-tools) émettant PKG_INSTALLED/SERVICE_ENABLED/ERR → réutilise buildPostInstallResult - l'UI post-install générique les expose automatiquement (manifeste → formulaire → run) tsc 0 · 104 tests · build OK · boot OK (8 profils servis). Clôt le volet moteur tâche 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
// 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[];
|
|
presetVars?: Record<string, string | number | boolean>; // variables fixes (ex. liste de paquets)
|
|
}
|
|
|
|
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" },
|
|
],
|
|
},
|
|
base_tools: {
|
|
id: "base_tools",
|
|
label: "Outils de base",
|
|
description: "nano, less, bash-completion, tmux, screen, htop, iotop, ncdu, tree, rsync, unzip, zip, tar.",
|
|
risk: "low",
|
|
requiresConfirmation: false,
|
|
template: "custom/install-package-groups.sh.tpl",
|
|
fields: [],
|
|
presetVars: { packages: "nano less bash-completion tmux screen htop iotop ncdu tree rsync unzip zip tar" },
|
|
},
|
|
network_tools: {
|
|
id: "network_tools",
|
|
label: "Outils réseau",
|
|
description: "iproute2, iputils-ping, dnsutils, traceroute, tcpdump, nmap, mtr-tiny, lsof, netcat-openbsd.",
|
|
risk: "low",
|
|
requiresConfirmation: false,
|
|
template: "custom/install-package-groups.sh.tpl",
|
|
fields: [],
|
|
presetVars: { packages: "iproute2 iputils-ping dnsutils traceroute tcpdump nmap mtr-tiny lsof netcat-openbsd" },
|
|
},
|
|
dev_git: {
|
|
id: "dev_git",
|
|
label: "Dev / Git",
|
|
description: "git, curl, wget, jq, gnupg, lsb-release.",
|
|
risk: "low",
|
|
requiresConfirmation: false,
|
|
template: "custom/install-package-groups.sh.tpl",
|
|
fields: [],
|
|
presetVars: { packages: "git curl wget jq gnupg lsb-release" },
|
|
},
|
|
docker_official: {
|
|
id: "docker_official",
|
|
label: "Docker (dépôt officiel)",
|
|
description: "Docker Engine depuis le dépôt officiel Debian + plugin compose ; ajoute l'utilisateur au groupe docker.",
|
|
risk: "medium",
|
|
requiresConfirmation: true,
|
|
template: "custom/docker-official-debian.sh.tpl",
|
|
fields: [
|
|
{ name: "dockerUser", type: "string", required: true, label: "Utilisateur docker", defaultFrom: "sshUser" },
|
|
{ name: "composeRoot", type: "path", required: true, label: "Dossier Compose", default: "/home/gilles/docker" },
|
|
{ name: "rebootAfterInstall", type: "bool", required: false, label: "Reboot après installation" },
|
|
],
|
|
},
|
|
sharing: {
|
|
id: "sharing",
|
|
label: "Partage réseau",
|
|
description: "Installe Samba / NFS / mDNS selon les cases cochées (configuration détaillée renvoyée à la tâche 4).",
|
|
risk: "medium",
|
|
requiresConfirmation: true,
|
|
template: "custom/sharing.sh.tpl",
|
|
fields: [
|
|
{ name: "installSamba", type: "bool", required: false, label: "Samba" },
|
|
{ name: "installNfs", type: "bool", required: false, label: "NFS" },
|
|
{ name: "installMdns", type: "bool", required: false, label: "mDNS (avahi)" },
|
|
],
|
|
},
|
|
vm_guest_tools: {
|
|
id: "vm_guest_tools",
|
|
label: "Outils invité VM",
|
|
description: "Agent invité selon l'hyperviseur (qemu-guest-agent ou open-vm-tools).",
|
|
risk: "low",
|
|
requiresConfirmation: false,
|
|
template: "custom/vm-guest-tools.sh.tpl",
|
|
fields: [
|
|
{ name: "guestAgent", type: "select", required: true, label: "Agent invité", default: "qemu-guest-agent", options: ["qemu-guest-agent", "open-vm-tools"] },
|
|
],
|
|
},
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 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({ ...manifest.presetVars, ...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({ ...manifest.presetVars, ...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" };
|
|
}
|