Cartographie complète (liste_taches/coherence_taches), briefs tacheN + gates validation_tacheN, design tâche 2 (docs/design/tache2/), specs/plans jalon 1-2 et tâche 1.9/2 (Phase 1, Phase 2, SJ-0→3). Validations consignées (1.9 ✅, 2-8 🟡). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 KiB
Tâche 2 — SJ-1 (APT update/analyse enrichi) — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
Goal: Introduire apt/update-analyze.sh.tpl (refresh index + simulations upgrade et dist-upgrade + held + reboot-check, non destructif), son parsing enrichi (AptSnapshotDetail : upgrade/dist-upgrade/installed/removed/held/rebootPkgs + statut ok|updates_available|warning|error), et basculer refreshMachine dessus via resolveTemplate, en conservant check.sh.tpl.
Architecture: Additif. Référence design : docs/design/tache2/10-templates-apt.md §4.1 (template) et 40-contrats-json.md §3 (AptSnapshotDetail). Le parsing est en TS (réutilise parseAptSimulate SJ-0/jalon 1) ; buildAptSnapshotDetail est une fonction pure testée sur fixtures. Le refresh bascule sur le nouveau template via resolveTemplate("update-analyze", osFamily) (fallback apt/). check.sh.tpl reste en place (non supprimé). Aucune rupture : snapshot.apt garde ses champs jalon 1 (enabled/count/rebootRequired/packages) + champs additifs.
Tech Stack: TypeScript, Mustache, vitest.
Invariants
snapshot.aptreste de forme jalon 1 (champs requis présents) ; on l'enrichit via les champs optionnels deAptSnapshotDetail(SJ-0).MachineStatus(union jalon 1, sans "warning") inchangée : le statutwarningvit danssnapshot.apt.status;snapshot.status(MachineStatus) mappe warning→updates_available.check.sh.tplconservé. Wiring : seulrefreshMachinebascule surupdate-analyze.- Tree partagé / WIP concurrent : ne toucher QUE
server/services/aptParse.ts(+test/fixtures),templates/apt/update-analyze.sh.tpl,server/services/refresh.ts,server/templates/render.test.tséventuel. Ne pas committer.
File Structure
server/services/aptParse.ts # MODIF : +parseAptRemovals/parseHeld/parseRebootDetail/buildAptSnapshotDetail
server/services/aptParse.test.ts # MODIF : +tests build detail
server/services/__fixtures__/apt-update-analyze.txt # NOUVEAU : sortie complète du template
templates/apt/update-analyze.sh.tpl # NOUVEAU
server/services/refresh.ts # MODIF : bascule sur update-analyze + detail
Task 1 : Parsing enrichi APT (TDD)
Files: Modify server/services/aptParse.ts, server/services/aptParse.test.ts ; Create server/services/__fixtures__/apt-update-analyze.txt.
- Step 1 : Créer la fixture
server/services/__fixtures__/apt-update-analyze.txt
===SU:APT_UPDATE===
Hit:1 http://deb.debian.org/debian bookworm InRelease
Reading package lists...
===SU:APT_SIM_UPGRADE===
Reading package lists...
Building dependency tree...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_SIM_DISTUPGRADE===
Reading package lists...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Inst newdep (1.0.0 Debian:11.6/stable [all])
Remv oldpkg [3.2-1]
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_HELD===
frozenpkg
===SU:REBOOT===
REBOOT_REQUIRED=1
PKG=linux-image-amd64
===SU:EXIT=0===
- Step 2 : Écrire le test (échec attendu) — ajouter à
server/services/aptParse.test.ts
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseAptRemovals, parseHeld, parseRebootDetail, buildAptSnapshotDetail } from "./aptParse.js";
const ua = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-update-analyze.txt", import.meta.url)), "utf8");
function section(raw: string, start: string, end: string): string {
const s = raw.indexOf(start); if (s === -1) return "";
const from = s + start.length; const e = raw.indexOf(end, from);
return raw.slice(from, e === -1 ? undefined : e).trim();
}
describe("parseAptRemovals", () => {
it("extrait les suppressions Remv", () => {
expect(parseAptRemovals("Remv oldpkg [3.2-1]\nInst x [1] (2 Y [amd64])"))
.toEqual([{ name: "oldpkg", currentVersion: "3.2-1" }]);
});
});
describe("parseHeld", () => {
it("liste les paquets retenus non vides", () => {
expect(parseHeld("frozenpkg\n\n other ")).toEqual(["frozenpkg", "other"]);
});
});
describe("parseRebootDetail", () => {
it("lit le flag et les paquets reboot", () => {
expect(parseRebootDetail("REBOOT_REQUIRED=1\nPKG=linux-image-amd64\nPKG=foo"))
.toEqual({ rebootRequired: true, pkgs: ["linux-image-amd64", "foo"] });
expect(parseRebootDetail("REBOOT_REQUIRED=0")).toEqual({ rebootRequired: false, pkgs: [] });
});
});
describe("buildAptSnapshotDetail", () => {
it("construit le détail enrichi depuis les sections", () => {
const detail = buildAptSnapshotDetail({
upgradeSim: section(ua, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
distUpgradeSim: section(ua, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
heldRaw: section(ua, "===SU:APT_HELD===", "===SU:REBOOT==="),
rebootRaw: section(ua, "===SU:REBOOT===", "===SU:EXIT"),
updateFailed: false,
});
expect(detail.enabled).toBe(true);
expect(detail.count).toBe(2); // 2 Inst en dist-upgrade
expect(detail.upgradeCount).toBe(1); // 1 Inst en upgrade
expect(detail.distUpgradeCount).toBe(2);
expect(detail.rebootRequired).toBe(true);
expect(detail.rebootPkgs).toEqual(["linux-image-amd64"]);
expect(detail.held).toEqual(["frozenpkg"]);
expect(detail.removed?.map((r) => r.name)).toEqual(["oldpkg"]);
expect(detail.installed?.map((p) => p.name)).toEqual(["newdep"]);
expect(detail.status).toBe("warning"); // car removed + held non vides
});
it("status=updates_available sans removed/held, error si update échoue", () => {
const ok = buildAptSnapshotDetail({ upgradeSim: "Inst a [1] (2 Y [amd64])", distUpgradeSim: "Inst a [1] (2 Y [amd64])", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: false });
expect(ok.status).toBe("updates_available");
const err = buildAptSnapshotDetail({ upgradeSim: "", distUpgradeSim: "", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: true });
expect(err.status).toBe("error");
});
});
-
Step 3 : Lancer (échec) —
rtk pnpm vitest run server/services/aptParse.test.ts→ FAIL. -
Step 4 : Étendre
server/services/aptParse.ts(garderparseAptSimulate/parseRebootRequiredexistants ; ajouter) :
import type { AptPackage, AptSnapshotDetail, SnapshotStatus } from "@shared/types.js";
// ... (parseAptSimulate, parseRebootRequired existants conservés) ...
const REMV_RE = /^Remv (\S+)(?: \[([^\]]+)\])?/;
export function parseAptRemovals(raw: string): { name: string; currentVersion: string | null }[] {
const out: { name: string; currentVersion: string | null }[] = [];
for (const line of raw.split("\n")) {
const m = REMV_RE.exec(line.trimEnd());
if (m) out.push({ name: m[1]!, currentVersion: m[2] ?? null });
}
return out;
}
export function parseHeld(raw: string): string[] {
return raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
}
export function parseRebootDetail(raw: string): { rebootRequired: boolean; pkgs: string[] } {
const rebootRequired = /REBOOT_REQUIRED=1/.test(raw);
const pkgs: string[] = [];
for (const line of raw.split("\n")) {
const m = /^PKG=(.+)$/.exec(line.trim());
if (m) pkgs.push(m[1]!.trim());
}
return { rebootRequired, pkgs };
}
export interface AptSections {
upgradeSim: string;
distUpgradeSim: string;
heldRaw: string;
rebootRaw: string;
updateFailed: boolean;
}
export function buildAptSnapshotDetail(s: AptSections): AptSnapshotDetail {
const upgradePkgs = parseAptSimulate(s.upgradeSim);
const distPkgs = parseAptSimulate(s.distUpgradeSim);
const installed: AptPackage[] = distPkgs
.filter((p) => p.currentVersion === null)
.map((p) => ({ ...p, operation: "install" }));
const removed: AptPackage[] = parseAptRemovals(s.distUpgradeSim).map((r) => ({
name: r.name, currentVersion: r.currentVersion, targetVersion: "", origin: null, operation: "remove",
}));
const held = parseHeld(s.heldRaw);
const { rebootRequired, pkgs: rebootPkgs } = parseRebootDetail(s.rebootRaw);
let status: SnapshotStatus = "ok";
if (s.updateFailed) status = "error";
else if (removed.length > 0 || held.length > 0) status = "warning";
else if (distPkgs.length > 0) status = "updates_available";
return {
enabled: true,
count: distPkgs.length,
rebootRequired,
packages: distPkgs,
status,
upgradeCount: upgradePkgs.length,
distUpgradeCount: distPkgs.length,
installed,
removed,
held,
rebootPkgs,
};
}
-
Step 5 : Lancer (succès) —
rtk pnpm vitest run server/services/aptParse.test.ts→ PASS.rtk pnpm check→ 0 erreur. -
Step 6 : (pas de commit)
Task 2 : Template apt/update-analyze.sh.tpl
Files: Create templates/apt/update-analyze.sh.tpl.
- Step 1 : Créer le template (depuis
10-templates-apt.md §4.1)
#!/bin/sh
# Refresh index + simulations upgrade/dist-upgrade + held + reboot-check.
# Exécuté entier sous sudo par la couche SSH. Non destructif.
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:APT_UPDATE==="
apt-get update -qq 2>&1
UPD=$?
echo "===SU:APT_SIM_UPGRADE==="
apt-get -s -y upgrade 2>&1
echo "===SU:APT_SIM_DISTUPGRADE==="
apt-get -s -y dist-upgrade 2>&1
echo "===SU:APT_HELD==="
apt-mark showhold 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then
echo "REBOOT_REQUIRED=1"
[ -f /var/run/reboot-required.pkgs ] && sed 's/^/PKG=/' /var/run/reboot-required.pkgs
else
echo "REBOOT_REQUIRED=0"
fi
echo "===SU:EXIT=${UPD}==="
- Step 2 : Vérifier le rendu —
rtk pnpm vitest run server/templates/render.test.tsreste vert (le test existant porte surcheck.sh.tpl; pas de régression). Optionnellement ajouter un cas :
it("rend update-analyze.sh.tpl avec les sections attendues", () => {
const out = renderTemplate("apt/update-analyze.sh.tpl", { aptProxy: null });
expect(out).toContain("===SU:APT_SIM_DISTUPGRADE===");
expect(out).toContain("apt-mark showhold");
});
- Step 3 : (pas de commit)
Task 3 : Basculer refreshMachine sur update-analyze
Files: Modify server/services/refresh.ts.
-
Step 1 : Relire
server/services/refresh.ts(état réel, incl. wiring Phase 1 machine_state/event). -
Step 2 : Adapter les imports
import { renderTemplate, resolveTemplate } from "../templates/render.js";
import {
parseAptSimulate, parseRebootRequired, // existants (peuvent rester importés)
buildAptSnapshotDetail,
} from "./aptParse.js";
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js";
- Step 3 : Remplacer la construction du snapshot dans
refreshMachine. Remplacer le rendu + le parsing actuels (check.sh.tpl,extractSection(...SIMULATE...)) par :
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const script = renderTemplate(resolveTemplate("update-analyze", m.osFamily), { aptProxy: proxy });
let raw = "";
try {
const res = await runScriptSudo(getCreds(m), script, (c) => {
raw += c;
outputHub.publish(machineId, c);
});
raw = res.stdout;
} catch (err) {
db.update(schema.machines).set({ status: "error" }).where(eq(schema.machines.id, machineId)).run();
throw err;
}
const updateExit = /===SU:EXIT=(\d+)===/.exec(raw);
const detail: AptSnapshotDetail = buildAptSnapshotDetail({
upgradeSim: extractSection(raw, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
distUpgradeSim: extractSection(raw, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
heldRaw: extractSection(raw, "===SU:APT_HELD===", "===SU:REBOOT==="),
rebootRaw: extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
updateFailed: updateExit ? Number(updateExit[1]) !== 0 : false,
});
// MachineStatus n'a pas "warning" : warning => updates_available côté machine.
const status: MachineStatus =
detail.status === "error" ? "error" : detail.count > 0 || detail.status === "warning" ? "updates_available" : "ok";
const checkedAt = new Date().toISOString();
const snapshot: UpdateSnapshot = {
machineId,
hostname: m.hostname,
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: m.osVersion ?? "" },
checkedAt,
status,
apt: detail,
schemaVersion: 1,
kind: "apt_update_analyze",
rawHints: { logImportantLines: reduceAptLines(raw) },
};
Conserver ensuite TOUT le bloc Phase 1 inchangé : insertion du snapshot (
kind/schemaVersion/importantJson), updatemachines,upsertMachineState(machineId, deriveAptState(snapshot)),recordEvent(...),return snapshot;.deriveAptStatelitsnapshot.status/apt.count/apt.rebootRequired/checkedAt— inchangé.
-
Step 4 : Vérifier —
rtk pnpm check && rtk pnpm vitest run server/services/refresh.test.ts server/services/aptParse.test.ts→ 0 erreur, tests verts (extractSection+ parsing). Note :check.sh.tpln'est plus référencé par le refresh mais reste sur disque (non supprimé), comme prévu. -
Step 5 : (pas de commit)
Task 4 : Vérification finale SJ-1
-
Step 1 :
rtk pnpm check && rtk pnpm test && rtk pnpm build→ 0 erreur, tous tests verts, build OK. -
Step 2 : Boot smoke (DB jetable) — confirmer que le serveur démarre (
/health) avec le refresh branché sur le nouveau template (pas d'exécution SSH réelle ici) :
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/sj1.db SU_REPORTS_DIR=./data/sj1-reports
node dist/index.js > ./data/sj1.log 2>&1 &
sleep 3; curl -s localhost:8787/health; kill %1 2>/dev/null
rm -rf ./data/sj1.db* ./data/sj1-reports ./data/sj1.log
Expected: {"ok":true}.
- Step 3 : Reporter. Note pour l'utilisateur : la vérif live (refresh réel sur une machine Debian) confirmera le parsing des vraies sorties
apt-get -s. Ne pas committer.
Self-Review (couverture SJ-1)
apt/update-analyze.sh.tpl(update + sim upgrade + sim dist-upgrade + held + reboot-check) → Task 2. ✓- parsing des sections +
AptSnapshotDetailenrichi (upgrade/dist/installed/removed/held/rebootPkgs + status) → Task 1 (TDD fixtures). ✓ - statut
ok|updates_available|warning|error→buildAptSnapshotDetail. ✓ - bascule du refresh sur update-analyze (via
resolveTemplate),check.sh.tplconservé → Task 3. ✓ - non-régression :
snapshot.aptgarde la forme jalon 1 ;MachineStatusinchangée (warning→updates_available) ; machine_state/events Phase 1 préservés. ✓
Décision : count = distUpgradeCount (toutes les mises à jour disponibles, cohérent avec le jalon 1 qui comptait la simulation full-upgrade). warning (removed/held) exposé dans apt.status, mappé updates_available pour machine.status. Noms cohérents : parseAptRemovals/parseHeld/parseRebootDetail/buildAptSnapshotDetail définis Task 1, utilisés Task 3.