From d1b0290e3bbc19878e7492aafceeb6584443e6e0 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Sat, 6 Jun 2026 18:41:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(apt):=20analyse=20des=20d=C3=A9p=C3=B4ts?= =?UTF-8?q?=20APT=20(lecture=20seule)=20(t=C3=A2che=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- client/src/features/machines/MachineTile.tsx | 29 ++++++- client/src/lib/api.ts | 3 +- server/routes/machines.ts | 10 +++ server/services/aptRepositories.test.ts | 40 ++++++++++ server/services/aptRepositories.ts | 80 ++++++++++++++++++++ shared/types.ts | 9 +++ templates/apt/repositories.sh.tpl | 8 ++ 7 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 server/services/aptRepositories.test.ts create mode 100644 server/services/aptRepositories.ts create mode 100644 templates/apt/repositories.sh.tpl diff --git a/client/src/features/machines/MachineTile.tsx b/client/src/features/machines/MachineTile.tsx index 9488c02..d44f675 100644 --- a/client/src/features/machines/MachineTile.tsx +++ b/client/src/features/machines/MachineTile.tsx @@ -1,6 +1,6 @@ // client/src/features/machines/MachineTile.tsx import { useEffect, useState } from "react"; -import type { ActionType, AptProxyMode, MachineMetricsSimple, MachineStatus, MachineView } from "@shared/types.js"; +import type { ActionType, AptProxyMode, AptRepositoriesAnalysis, MachineMetricsSimple, MachineStatus, MachineView } from "@shared/types.js"; import { Button, Icon, IconButton, Popup, StatusLed } from "../../components/ui-kit.js"; import { api, @@ -218,6 +218,12 @@ function MachineConfigPopup({ const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); const [proxyMode, setProxyMode] = useState(machine.aptProxyMode); const [proxyUrl, setProxyUrl] = useState(machine.aptProxyUrl ?? ""); + const [repos, setRepos] = useState(null); + + const analyzeRepos = () => + withBusy("repos", async () => { + setRepos(await api.analyzeRepositories(machine.id)); + }); async function withBusy(key: string, fn: () => Promise) { setBusy(key); @@ -355,6 +361,27 @@ function MachineConfigPopup({ +
+
+ Dépôts APT (analyse) + +
+ {repos && ( +
+
composants : {repos.components.join(", ") || "—"}
+ {repos.proxmox && ( +
+ pve enterprise={String(repos.proxmox.enterprise)} · no-subscription={String(repos.proxmox.noSubscription)} +
+ )} + {repos.warnings.map((w, i) => {w.message})} + {repos.notes.map((n, i) => {n})} +
+ )} +
+ {msg &&

{msg.text}

} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 744a01e..b6a6e08 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,5 +1,5 @@ // client/src/lib/api.ts -import type { ActionType, AptProxyMode, MachineKind, MachineMetricsSimple, MachineView, OsFamily, SystemMetrics, UpdateSnapshot } from "@shared/types.js"; +import type { ActionType, AptProxyMode, AptRepositoriesAnalysis, MachineKind, MachineMetricsSimple, MachineView, OsFamily, SystemMetrics, UpdateSnapshot } from "@shared/types.js"; async function readJsonBody(res: Response): Promise { const text = await res.text(); @@ -62,6 +62,7 @@ export const api = { machineHardware: (id: string) => req(`/machines/${id}/hardware`), latestMetrics: (id: string) => req(`/machines/${id}/metrics`), collectMetrics: (id: string) => req(`/machines/${id}/metrics/collect`, { method: "POST" }), + analyzeRepositories: (id: string) => req(`/machines/${id}/apt-repositories`, { method: "POST" }), // --- Docker --- dockerSettings: (id: string) => req(`/machines/${id}/docker/settings`), diff --git a/server/routes/machines.ts b/server/routes/machines.ts index 3851bee..4644d9e 100644 --- a/server/routes/machines.ts +++ b/server/routes/machines.ts @@ -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"))); diff --git a/server/services/aptRepositories.test.ts b/server/services/aptRepositories.test.ts new file mode 100644 index 0000000..f76b1f3 --- /dev/null +++ b/server/services/aptRepositories.test.ts @@ -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); + }); +}); diff --git a/server/services/aptRepositories.ts b/server/services/aptRepositories.ts new file mode 100644 index 0000000..f6d4aae --- /dev/null +++ b/server/services/aptRepositories.ts @@ -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 { + 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); +} diff --git a/shared/types.ts b/shared/types.ts index ccff3d4..7f0a41d 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -208,6 +208,15 @@ export interface RebootResult { errors?: SnapshotError[]; } +export interface AptRepositoriesAnalysis { + osFamily: OsFamily; + components: string[]; + repos: { uri: string; suite: string; components: string[] }[]; + proxmox?: { enterprise: boolean; noSubscription: boolean }; + warnings: { kind: string; message: string }[]; + notes: string[]; +} + export interface MachineMetricsSimple { collectedAt: string; cpu: { load1: number | null; load5: number | null; cores: number | null }; diff --git a/templates/apt/repositories.sh.tpl b/templates/apt/repositories.sh.tpl new file mode 100644 index 0000000..7bae67c --- /dev/null +++ b/templates/apt/repositories.sh.tpl @@ -0,0 +1,8 @@ +#!/bin/sh +# Analyse des dépôts APT (lecture seule). Ne modifie rien. +export LC_ALL=C +echo "===SU:REPO_DEB===" +grep -rhE '^[[:space:]]*deb[[:space:]]' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null | grep -vE '^[[:space:]]*#' +echo "===SU:REPO_DEB822===" +grep -rhE '^(URIs|Suites|Components|Enabled):' /etc/apt/sources.list.d/ 2>/dev/null +echo "===SU:EXIT=0==="