feat(os): profils Proxmox/RPi + machine_probe + proxy persistent (tâche 2 SJ-7)

- templates proxmox/ (update-analyze: dépôts PVE ; full-upgrade) et raspbian/
  (update-analyze: espace disque ; full-upgrade)
- execute résout les actions APT par profil OS (resolveTemplate) → proxmox/
  raspbian si dispo, sinon fallback apt/ (non-régression debian/ubuntu vérifiée)
- machine_probe (lecture seule) : template + parseProbe/proposeCorrections (TDD)
  → propose os_family/machine_kind/virtualization, persiste machine_hardware,
  n'applique jamais auto ; branche execute + allowlist route
- apt_proxy_persistent : ActionType + template idempotent (/etc/apt/apt.conf.d/
  01proxy, backup) + TemplateVars.aptProxyUrl + allowlist route

tsc 0 · 95 tests · build OK · résolution OS vérifiée.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 07:14:43 +02:00
parent b5ec14dcd8
commit bafb085995
13 changed files with 490 additions and 15 deletions
+3
View File
@@ -16,6 +16,9 @@ const ALLOWED_ACTIONS: ActionType[] = [
"docker_scan",
"docker_inspect_current",
"docker_pull_check",
// SJ-7 : sonde (lecture seule) + proxy APT persistant (action explicite idempotente).
"machine_probe",
"apt_proxy_persistent",
];
// Actions Docker ciblant un stack précis : stackId obligatoire.
const NEED_STACK: ActionType[] = ["docker_inspect_current", "docker_pull_check"];
+41 -15
View File
@@ -6,7 +6,7 @@ import { join } from "node:path";
import { db, schema } from "../db/client.js";
import { env } from "../env.js";
import { getMachineRow, getCreds } from "./machines.js";
import { renderTemplate } from "../templates/render.js";
import { renderTemplate, resolveTemplate } from "../templates/render.js";
import { reduceAptLines } from "../templates/aptReduce.js";
import { runScriptSudo } from "../ssh/client.js";
import { parseRebootRequired, buildAptExecutionResult } from "./aptParse.js";
@@ -18,16 +18,16 @@ import { outputHub } from "../ws/outputHub.js";
import { upsertMachineState, recordEvent } from "./machineState.js";
import type { ActionType, AptExecutionResult, ExecutionResult, ExecutionStatus } from "@shared/types.js";
const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
apt_full_upgrade: "apt/full-upgrade.sh.tpl",
apt_upgrade: "apt/upgrade.sh.tpl",
apt_autoremove: "apt/autoremove.sh.tpl",
apt_clean: "apt/clean.sh.tpl",
reboot: "apt/reboot.sh.tpl",
reboot_verified: "apt/reboot.sh.tpl",
// SJ-4 Docker (passif)
docker_scan: "docker/scan-compose.sh.tpl",
docker_inspect_current: "docker/inspect-compose.sh.tpl",
// Actions APT/système résolues par profil OS (resolveTemplate → proxmox/raspbian si dispo,
// sinon fallback apt/). La valeur est le basename d'action (sans dossier ni extension).
const APT_ACTION_FILE: Partial<Record<ActionType, string>> = {
apt_full_upgrade: "full-upgrade",
apt_upgrade: "upgrade",
apt_autoremove: "autoremove",
apt_clean: "clean",
reboot: "reboot",
reboot_verified: "reboot",
apt_proxy_persistent: "apt-proxy-persistent",
};
export interface RunActionOpts {
@@ -333,16 +333,42 @@ export async function runAction(
}
}
// --- SJ-7 : sonde machine (lecture seule) déléguée au service dédié ---
if (action === "machine_probe") {
const { runProbe } = await import("./machineProbe.js");
try {
const o = await runProbe(machineId, () => {});
const important = [
`machine_probe : os=${o.probe.osId ?? "?"} ${o.probe.osVersion ?? ""} arch=${o.probe.arch ?? "?"} virt=${o.probe.virt ?? "?"}`,
`proposition : os_family=${o.proposal.osFamily} machine_kind=${o.proposal.machineKind} virtualization=${o.proposal.virtualization}`,
...(o.changes.length ? ["corrections proposées (non appliquées) :", ...o.changes.map((c) => ` ${c}`)] : ["aucune correction proposée"]),
];
outputHub.publish(machineId, `\n===SU:DONE status=ok===\n`);
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "ok", raw: o.raw, importantLines: important });
} catch (err) {
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
}
}
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const rel = TEMPLATE_FOR[action];
if (!rel) throw new Error("Action sans template: " + action);
// Templates Docker par-stack (inspect) : injecter stackDir ; ignoré par les templates APT.
// Résolution du template : Docker inspect = chemin direct ; sinon résolution par profil OS.
let rel: string;
if (action === "docker_inspect_current") {
rel = "docker/inspect-compose.sh.tpl";
} else {
const file = APT_ACTION_FILE[action];
if (!file) throw new Error("Action sans template: " + action);
rel = resolveTemplate(file, m.osFamily);
}
// Docker inspect par-stack : injecter stackDir ; ignoré par les templates APT.
let stackDir: string | null = null;
if (opts?.stackId) {
const st = db.select().from(schema.dockerComposeStacks).where(eq(schema.dockerComposeStacks.id, opts.stackId)).get();
stackDir = st?.workingDir ?? null;
}
const script = renderTemplate(rel, { aptProxy: proxy, stackDir });
// Proxy persistant : l'URL est passée comme variable de template (jamais un secret).
const aptProxyUrl = action === "apt_proxy_persistent" ? m.aptProxyUrl : null;
const script = renderTemplate(rel, { aptProxy: proxy, stackDir, aptProxyUrl });
const inactivity = action === "reboot" ? 0 : 600000;
+103
View File
@@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import { parseProbe, proposeCorrections } from "./machineProbe.js";
const PROXMOX = [
"===SU:PROBE_OS===",
'PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"',
"ID=debian",
'VERSION_ID="12"',
"VERSION_CODENAME=bookworm",
"===SU:PROBE_ARCH===",
"x86_64",
"amd64",
"===SU:PROBE_VIRT===",
"none",
"===SU:PROBE_PROXMOX===",
"PROXMOX=1",
"===SU:PROBE_RPI===",
"RPI=0",
"===SU:PROBE_GPU===",
"01:00.0 VGA compatible controller: Matrox MGA G200eW",
"===SU:PROBE_NET===",
"vmbr0 10.0.3.202/24",
"===SU:EXIT=0===",
].join("\n");
const RPI = [
"===SU:PROBE_OS===",
"ID=debian",
"VERSION_CODENAME=bookworm",
"===SU:PROBE_ARCH===",
"aarch64",
"arm64",
"===SU:PROBE_VIRT===",
"none",
"===SU:PROBE_PROXMOX===",
"PROXMOX=0",
"===SU:PROBE_RPI===",
"RPI=1",
"===SU:PROBE_GPU===",
"no-lspci",
"===SU:PROBE_NET===",
"eth0 192.168.1.50/24",
"===SU:EXIT=0===",
].join("\n");
const KVM_VM = [
"===SU:PROBE_OS===",
"ID=ubuntu",
'VERSION_ID="24.04"',
"VERSION_CODENAME=noble",
"===SU:PROBE_ARCH===",
"x86_64",
"amd64",
"===SU:PROBE_VIRT===",
"kvm",
"===SU:PROBE_PROXMOX===",
"PROXMOX=0",
"===SU:PROBE_RPI===",
"RPI=0",
"===SU:PROBE_GPU===",
"no-lspci",
"===SU:PROBE_NET===",
"ens18 10.0.3.5/24",
"===SU:EXIT=0===",
].join("\n");
describe("parseProbe", () => {
it("extrait os-release, arch, virt et drapeaux", () => {
const p = parseProbe(PROXMOX);
expect(p.osId).toBe("debian");
expect(p.osVersion).toBe("12");
expect(p.osCodename).toBe("bookworm");
expect(p.arch).toBe("x86_64");
expect(p.dpkgArch).toBe("amd64");
expect(p.virt).toBe("none");
expect(p.isProxmox).toBe(true);
expect(p.isRpi).toBe(false);
expect(p.gpus).toHaveLength(1);
expect(p.net).toEqual([{ iface: "vmbr0", addr: "10.0.3.202/24" }]);
});
});
describe("proposeCorrections", () => {
it("Proxmox → os_family proxmox + machine_kind proxmox_host", () => {
const c = proposeCorrections(parseProbe(PROXMOX));
expect(c.osFamily).toBe("proxmox");
expect(c.machineKind).toBe("proxmox_host");
expect(c.virtualization).toBe("none");
});
it("Raspberry Pi → raspbian + raspberry_pi", () => {
const c = proposeCorrections(parseProbe(RPI));
expect(c.osFamily).toBe("raspbian");
expect(c.machineKind).toBe("raspberry_pi");
});
it("VM KVM Ubuntu → ubuntu + vm + virtualization kvm", () => {
const c = proposeCorrections(parseProbe(KVM_VM));
expect(c.osFamily).toBe("ubuntu");
expect(c.machineKind).toBe("vm");
expect(c.virtualization).toBe("kvm");
});
});
+155
View File
@@ -0,0 +1,155 @@
// server/services/machineProbe.ts
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import { getMachineRow, getCreds } from "./machines.js";
import { renderTemplate } from "../templates/render.js";
import { runScriptSudo } from "../ssh/client.js";
import { outputHub } from "../ws/outputHub.js";
import type { OsFamily, MachineKind } from "@shared/types.js";
// ----------------------------------------------------------------------------
// Fonctions pures (testables).
// ----------------------------------------------------------------------------
export interface ProbeResult {
osId: string | null;
osVersion: string | null;
osCodename: string | null;
arch: string | null;
dpkgArch: string | null;
virt: string | null;
isProxmox: boolean;
isRpi: boolean;
gpus: string[];
net: { iface: string; addr: string }[];
}
export interface CorrectionProposal {
osFamily: OsFamily;
machineKind: MachineKind;
virtualization: string;
}
function section(raw: string, start: string, end?: string): string {
const i = raw.indexOf(start);
if (i < 0) return "";
const from = i + start.length;
const j = end ? raw.indexOf(end, from) : -1;
return raw.slice(from, j < 0 ? undefined : j).trim();
}
function osReleaseValue(block: string, key: string): string | null {
const m = new RegExp(`^${key}=(.*)$`, "m").exec(block);
if (!m || m[1] === undefined) return null;
return m[1].replace(/^"(.*)"$/, "$1").trim() || null;
}
export function parseProbe(raw: string): ProbeResult {
const os = section(raw, "===SU:PROBE_OS===", "===SU:PROBE_ARCH===");
const archBlock = section(raw, "===SU:PROBE_ARCH===", "===SU:PROBE_VIRT===").split("\n");
const virt = section(raw, "===SU:PROBE_VIRT===", "===SU:PROBE_PROXMOX===").split("\n")[0]?.trim() || null;
const prox = section(raw, "===SU:PROBE_PROXMOX===", "===SU:PROBE_RPI===");
const rpi = section(raw, "===SU:PROBE_RPI===", "===SU:PROBE_GPU===");
const gpuBlock = section(raw, "===SU:PROBE_GPU===", "===SU:PROBE_NET===");
const netBlock = section(raw, "===SU:PROBE_NET===", "===SU:EXIT=");
const gpus = gpuBlock
.split("\n")
.map((l) => l.trim())
.filter((l) => l && l !== "no-lspci");
const net: ProbeResult["net"] = [];
for (const line of netBlock.split("\n")) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && parts[0] && parts[1] && parts[0] !== "lo") {
net.push({ iface: parts[0], addr: parts[1] });
}
}
return {
osId: osReleaseValue(os, "ID"),
osVersion: osReleaseValue(os, "VERSION_ID"),
osCodename: osReleaseValue(os, "VERSION_CODENAME"),
arch: archBlock[0]?.trim() || null,
dpkgArch: archBlock[1]?.trim() || null,
virt,
isProxmox: /PROXMOX=1/.test(prox),
isRpi: /RPI=1/.test(rpi),
gpus,
net,
};
}
const VM_VIRTS = new Set(["kvm", "qemu", "vmware", "oracle", "microsoft", "xen", "bochs", "parallels"]);
const LXC_VIRTS = new Set(["lxc", "lxc-libvirt", "openvz", "systemd-nspawn", "docker", "podman"]);
export function proposeCorrections(p: ProbeResult): CorrectionProposal {
const virtualization = p.virt && p.virt !== "none" ? p.virt : "none";
let osFamily: OsFamily;
if (p.isProxmox) osFamily = "proxmox";
else if (p.isRpi) osFamily = "raspbian";
else if (p.osId === "ubuntu") osFamily = "ubuntu";
else if (p.osId === "debian" || p.osId === "raspbian") osFamily = "debian";
else osFamily = "unknown";
let machineKind: MachineKind;
if (p.isProxmox) machineKind = "proxmox_host";
else if (p.isRpi) machineKind = "raspberry_pi";
else if (p.virt && VM_VIRTS.has(p.virt)) machineKind = "vm";
else if (p.virt && LXC_VIRTS.has(p.virt)) machineKind = "lxc";
else if (p.virt === "none") machineKind = "physical";
else machineKind = "unknown";
return { osFamily, machineKind, virtualization };
}
// ----------------------------------------------------------------------------
// Orchestration (SSH, lecture seule). Persiste les faits matériels ; ne corrige PAS
// os_family/machine_kind automatiquement — la proposition est renvoyée pour validation.
// ----------------------------------------------------------------------------
export interface ProbeOutcome {
probe: ProbeResult;
proposal: CorrectionProposal;
raw: string;
changes: string[]; // diff entre l'actuel et la proposition (pour l'UI)
}
export async function runProbe(machineId: string, onData?: (c: string) => void): Promise<ProbeOutcome> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
const script = renderTemplate("apt/machine-probe.sh.tpl", {});
const res = await runScriptSudo(getCreds(m), script, (c) => {
onData?.(c);
outputHub.publish(machineId, c);
});
const raw = res.stdout;
const probe = parseProbe(raw);
const proposal = proposeCorrections(probe);
const now = new Date().toISOString();
db.insert(schema.machineHardware)
.values({
machineId,
gpusJson: JSON.stringify(probe.gpus),
networkJson: JSON.stringify(probe.net),
updatedAt: now,
})
.onConflictDoUpdate({
target: schema.machineHardware.machineId,
set: { gpusJson: JSON.stringify(probe.gpus), networkJson: JSON.stringify(probe.net), updatedAt: now },
})
.run();
const changes: string[] = [];
if (proposal.osFamily !== m.osFamily) changes.push(`os_family: ${m.osFamily}${proposal.osFamily}`);
if (proposal.machineKind !== (m.machineKind ?? "unknown")) {
changes.push(`machine_kind: ${m.machineKind ?? "—"}${proposal.machineKind}`);
}
if (proposal.virtualization !== (m.virtualization ?? "none")) {
changes.push(`virtualization: ${m.virtualization ?? "—"}${proposal.virtualization}`);
}
return { probe, proposal, raw, changes };
}
+1
View File
@@ -7,6 +7,7 @@ const TEMPLATES_ROOT = resolve(process.cwd(), "templates");
export interface TemplateVars {
aptProxy?: string | null;
aptProxyUrl?: string | null; // proxy persistant (apt_proxy_persistent)
// Docker template vars
composeRoots?: string | number | null;
composeScanDepth?: string | number | null;