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:
@@ -1,6 +1,6 @@
|
|||||||
// client/src/features/machines/MachineTile.tsx
|
// client/src/features/machines/MachineTile.tsx
|
||||||
import { useEffect, useState } from "react";
|
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 { Button, Icon, IconButton, Popup, StatusLed } from "../../components/ui-kit.js";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -218,6 +218,12 @@ function MachineConfigPopup({
|
|||||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
const [proxyMode, setProxyMode] = useState<AptProxyMode>(machine.aptProxyMode);
|
const [proxyMode, setProxyMode] = useState<AptProxyMode>(machine.aptProxyMode);
|
||||||
const [proxyUrl, setProxyUrl] = useState(machine.aptProxyUrl ?? "");
|
const [proxyUrl, setProxyUrl] = useState(machine.aptProxyUrl ?? "");
|
||||||
|
const [repos, setRepos] = useState<AptRepositoriesAnalysis | null>(null);
|
||||||
|
|
||||||
|
const analyzeRepos = () =>
|
||||||
|
withBusy("repos", async () => {
|
||||||
|
setRepos(await api.analyzeRepositories(machine.id));
|
||||||
|
});
|
||||||
|
|
||||||
async function withBusy(key: string, fn: () => Promise<void>) {
|
async function withBusy(key: string, fn: () => Promise<void>) {
|
||||||
setBusy(key);
|
setBusy(key);
|
||||||
@@ -355,6 +361,27 @@ function MachineConfigPopup({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="cfg-block">
|
||||||
|
<div className="cfg-block-head">
|
||||||
|
<span className="label">Dépôts APT (analyse)</span>
|
||||||
|
<Button icon="logs" size="sm" onClick={busy ? undefined : analyzeRepos}>
|
||||||
|
{busy === "repos" ? "Analyse…" : "Analyser"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{repos && (
|
||||||
|
<div className="cfg-probe">
|
||||||
|
<div className="mono cfg-facts">composants : {repos.components.join(", ") || "—"}</div>
|
||||||
|
{repos.proxmox && (
|
||||||
|
<div className="mono cfg-facts">
|
||||||
|
pve enterprise={String(repos.proxmox.enterprise)} · no-subscription={String(repos.proxmox.noSubscription)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{repos.warnings.map((w, i) => <span key={i} className="docker-msg docker-msg-err">{w.message}</span>)}
|
||||||
|
{repos.notes.map((n, i) => <span key={i} className="cfg-nochange">{n}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{msg && <p className={`docker-msg ${msg.kind === "err" ? "docker-msg-err" : "docker-msg-ok"}`}>{msg.text}</p>}
|
{msg && <p className={`docker-msg ${msg.kind === "err" ? "docker-msg-err" : "docker-msg-ok"}`}>{msg.text}</p>}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// client/src/lib/api.ts
|
// 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<unknown> {
|
async function readJsonBody(res: Response): Promise<unknown> {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@@ -62,6 +62,7 @@ export const api = {
|
|||||||
machineHardware: (id: string) => req<MachineHardwareView>(`/machines/${id}/hardware`),
|
machineHardware: (id: string) => req<MachineHardwareView>(`/machines/${id}/hardware`),
|
||||||
latestMetrics: (id: string) => req<MachineMetricsSimple | null>(`/machines/${id}/metrics`),
|
latestMetrics: (id: string) => req<MachineMetricsSimple | null>(`/machines/${id}/metrics`),
|
||||||
collectMetrics: (id: string) => req<MachineMetricsSimple>(`/machines/${id}/metrics/collect`, { method: "POST" }),
|
collectMetrics: (id: string) => req<MachineMetricsSimple>(`/machines/${id}/metrics/collect`, { method: "POST" }),
|
||||||
|
analyzeRepositories: (id: string) => req<AptRepositoriesAnalysis>(`/machines/${id}/apt-repositories`, { method: "POST" }),
|
||||||
|
|
||||||
// --- Docker ---
|
// --- Docker ---
|
||||||
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
|
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { refreshMachine, getLatestSnapshot } from "../services/refresh.js";
|
import { refreshMachine, getLatestSnapshot } from "../services/refresh.js";
|
||||||
import { runProbe } from "../services/machineProbe.js";
|
import { runProbe } from "../services/machineProbe.js";
|
||||||
import { collectMetrics, getLatestMetrics } from "../services/machineMetrics.js";
|
import { collectMetrics, getLatestMetrics } from "../services/machineMetrics.js";
|
||||||
|
import { analyzeMachineRepositories } from "../services/aptRepositories.js";
|
||||||
|
|
||||||
export const machinesRoutes = new Hono();
|
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) => {
|
machinesRoutes.get("/:id/hardware", (c) => {
|
||||||
try {
|
try {
|
||||||
return c.json(getMachineHardware(c.req.param("id")));
|
return c.json(getMachineHardware(c.req.param("id")));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -208,6 +208,15 @@ export interface RebootResult {
|
|||||||
errors?: SnapshotError[];
|
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 {
|
export interface MachineMetricsSimple {
|
||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
cpu: { load1: number | null; load5: number | null; cores: number | null };
|
cpu: { load1: number | null; load5: number | null; cores: number | null };
|
||||||
|
|||||||
@@ -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==="
|
||||||
Reference in New Issue
Block a user