feat(apt): analyse des dépôts APT (lecture seule) (tâche 4)

- template repositories (deb lines + deb822), non destructif
- analyzeRepositories (TDD) : composants, repos, détection Proxmox
  enterprise/no-subscription, warnings (pve_enterprise_without_subscription,
  pve_repo_missing) + notes Debian/Ubuntu composants manquants
- route POST /machines/:id/apt-repositories ; api analyzeRepositories
- popup config : bloc « Dépôts APT » (composants + warnings + notes)

Analyse uniquement (modification = action validée séparée, future). tsc 0 · 113 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 18:41:11 +02:00
parent e3e824185f
commit d1b0290e3b
7 changed files with 177 additions and 2 deletions
+10
View File
@@ -8,6 +8,7 @@ import {
import { refreshMachine, getLatestSnapshot } from "../services/refresh.js";
import { runProbe } from "../services/machineProbe.js";
import { collectMetrics, getLatestMetrics } from "../services/machineMetrics.js";
import { analyzeMachineRepositories } from "../services/aptRepositories.js";
export const machinesRoutes = new Hono();
@@ -67,6 +68,15 @@ machinesRoutes.post("/:id/metrics/collect", async (c) => {
}
});
// Analyse des dépôts APT (lecture seule).
machinesRoutes.post("/:id/apt-repositories", async (c) => {
try {
return c.json(await analyzeMachineRepositories(c.req.param("id")));
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
machinesRoutes.get("/:id/hardware", (c) => {
try {
return c.json(getMachineHardware(c.req.param("id")));
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { analyzeRepositories } from "./aptRepositories.js";
const DEBIAN = [
"===SU:REPO_DEB===",
"deb http://deb.debian.org/debian bookworm main contrib",
"deb http://security.debian.org/debian-security bookworm-security main",
"===SU:REPO_DEB822===",
"===SU:EXIT=0===",
].join("\n");
const PROXMOX_ENTERPRISE = [
"===SU:REPO_DEB===",
"deb http://ftp.debian.org/debian bookworm main contrib",
"deb https://enterprise.proxmox.com/debian/pve bookworm pve-enterprise",
"===SU:REPO_DEB822===",
"===SU:EXIT=0===",
].join("\n");
describe("analyzeRepositories", () => {
it("Debian : composants détectés et non-free-firmware absent → note", () => {
const a = analyzeRepositories("debian", DEBIAN);
expect(a.components).toContain("main");
expect(a.components).toContain("contrib");
expect(a.repos.length).toBeGreaterThanOrEqual(2);
expect(a.notes.some((n) => /non-free-firmware/.test(n))).toBe(true);
});
it("Proxmox : dépôt enterprise sans no-subscription → warning", () => {
const a = analyzeRepositories("proxmox", PROXMOX_ENTERPRISE);
expect(a.proxmox?.enterprise).toBe(true);
expect(a.proxmox?.noSubscription).toBe(false);
expect(a.warnings.some((w) => w.kind === "pve_enterprise_without_subscription")).toBe(true);
});
it("Proxmox : aucun dépôt PVE → warning", () => {
const a = analyzeRepositories("proxmox", DEBIAN);
expect(a.warnings.some((w) => w.kind === "pve_repo_missing")).toBe(true);
});
});
+80
View File
@@ -0,0 +1,80 @@
// server/services/aptRepositories.ts
import { getMachineRow, getCreds } from "./machines.js";
import { renderTemplate } from "../templates/render.js";
import { runScriptSudo } from "../ssh/client.js";
import type { AptRepositoriesAnalysis, OsFamily } from "@shared/types.js";
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();
}
interface Repo {
uri: string;
suite: string;
components: string[];
}
/** Parse les lignes `deb [opts] URI suite comp...` (format une-ligne). */
function parseDebLines(block: string): Repo[] {
const repos: Repo[] = [];
for (const line of block.split("\n")) {
const t = line.trim();
if (!t.startsWith("deb ") && !t.startsWith("deb\t")) continue;
// retire le mot-clé deb et les options [arch=...]
const rest = t.replace(/^deb\s+/, "").replace(/^\[[^\]]*\]\s*/, "");
const parts = rest.split(/\s+/).filter(Boolean);
if (parts.length < 2) continue;
const [uri, suite, ...components] = parts;
repos.push({ uri: uri!, suite: suite!, components });
}
return repos;
}
export function analyzeRepositories(osFamily: OsFamily, raw: string): AptRepositoriesAnalysis {
const repos = parseDebLines(section(raw, "===SU:REPO_DEB===", "===SU:REPO_DEB822==="));
const components = [...new Set(repos.flatMap((r) => r.components))].sort();
const warnings: AptRepositoriesAnalysis["warnings"] = [];
const notes: string[] = [];
if (osFamily === "proxmox") {
const enterprise = repos.some((r) => /enterprise\.proxmox\.com/.test(r.uri));
const noSubscription = repos.some((r) => /download\.proxmox\.com/.test(r.uri) && r.components.includes("pve-no-subscription"));
if (enterprise && !noSubscription) {
warnings.push({
kind: "pve_enterprise_without_subscription",
message: "Dépôt PVE entreprise actif sans dépôt no-subscription : `apt update` échouera sans abonnement.",
});
}
if (!enterprise && !noSubscription) {
warnings.push({ kind: "pve_repo_missing", message: "Aucun dépôt PVE détecté (ni enterprise ni no-subscription)." });
}
return { osFamily, components, repos, proxmox: { enterprise, noSubscription }, warnings, notes };
}
if (osFamily === "debian") {
for (const comp of ["contrib", "non-free", "non-free-firmware"]) {
if (!components.includes(comp)) notes.push(`Composant « ${comp} » absent (requis pour firmware/drivers propriétaires).`);
}
} else if (osFamily === "ubuntu") {
for (const comp of ["universe", "restricted", "multiverse"]) {
if (!components.includes(comp)) notes.push(`Composant « ${comp} » absent (drivers/paquets supplémentaires indisponibles).`);
}
}
if (repos.length === 0) warnings.push({ kind: "no_sources", message: "Aucune source APT détectée." });
return { osFamily, components, repos, warnings, notes };
}
/** Analyse les dépôts APT d'une machine via SSH (lecture seule). */
export async function analyzeMachineRepositories(machineId: string): Promise<AptRepositoriesAnalysis> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
const script = renderTemplate("apt/repositories.sh.tpl", {});
const res = await runScriptSudo(getCreds(m), script, () => {});
return analyzeRepositories(m.osFamily as OsFamily, res.stdout);
}