# 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.apt` reste de forme jalon 1 (champs requis présents) ; on l'enrichit via les champs optionnels de `AptSnapshotDetail` (SJ-0). - `MachineStatus` (union jalon 1, sans "warning") **inchangée** : le statut `warning` vit dans `snapshot.apt.status` ; `snapshot.status` (MachineStatus) mappe warning→`updates_available`. - `check.sh.tpl` conservé. Wiring : seul `refreshMachine` bascule sur `update-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` ```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`** (garder `parseAptSimulate`/`parseRebootRequired` existants ; ajouter) : ```ts 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`) ```sh #!/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.ts` reste vert (le test existant porte sur `check.sh.tpl` ; pas de régression). Optionnellement ajouter un cas : ```ts 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** ```ts 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 : ```ts 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`), update `machines`, `upsertMachineState(machineId, deriveAptState(snapshot))`, `recordEvent(...)`, `return snapshot;`. `deriveAptState` lit `snapshot.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.tpl` n'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) : ```bash 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 + `AptSnapshotDetail` enrichi (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.tpl` conservé → Task 3. ✓ - non-régression : `snapshot.apt` garde la forme jalon 1 ; `MachineStatus` inchangé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.