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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user