# Tâche 2 — SJ-2 (APT apply + diff dpkg réel) — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox. **Goal:** Enrichir `apt/full-upgrade.sh.tpl` du snapshot dpkg avant/après, ajouter `apt/upgrade.sh.tpl`, `apt/autoremove.sh.tpl`, `apt/clean.sh.tpl`, calculer le **diff dpkg réel** (`AptExecutionResult` : applied/installed/removed), brancher les actions `apt_upgrade`/`apt_autoremove`/`apt_clean` (+ `apt_full_upgrade` enrichi) dans `runAction`, et ajouter un **timeout d'inactivité** optionnel à la couche SSH. **Architecture:** Additif. Référence : `docs/design/tache2/10-templates-apt.md §4.2-4.4`, `40-contrats-json.md §4` (`AptExecutionResult`/`AptChange`), `50-erreurs.md` (`human_interaction_required`). Le diff dpkg est calculé en TS (`buildAptExecutionResult`, pure, TDD). `runScriptSudo` reçoit une option `inactivityTimeoutMs` (défaut 0 = désactivé ⇒ comportement jalon 1 inchangé) ; `runAction` la passe (600000) pour les actions APT. Les confirmations UI des suppressions relèvent de la tâche 3 ; SJ-2 expose `removed[]` dans le résultat. **Tech Stack:** TypeScript, Mustache, ssh2, vitest. --- ## Invariants - `apt_full_upgrade` et `reboot` (jalon 1) restent fonctionnels ; on **enrichit** sans casser le parsing exit/reboot existant de `execute.ts`. - `runScriptSudo` : nouveau paramètre **optionnel** `inactivityTimeoutMs` (défaut 0 = pas de timeout) ⇒ `refreshMachine` et tout appelant existant **inchangés** de comportement. - `ExecutionResult.apt` est optionnel (SJ-0) ⇒ une exécution sans diff reste valide. - Tree partagé / WIP concurrent : ne toucher QUE `server/services/aptParse.ts` (+test/fixtures), `templates/apt/{full-upgrade,upgrade,autoremove,clean}.sh.tpl`, `server/ssh/client.ts`, `server/services/execute.ts`. **Ne pas committer.** ## File Structure ``` server/services/aptParse.ts # MODIF : +parseDpkgList/diffDpkg/buildAptExecutionResult server/services/aptParse.test.ts # MODIF : +tests diff dpkg templates/apt/full-upgrade.sh.tpl # MODIF : +DPKG_BEFORE/AFTER templates/apt/upgrade.sh.tpl # NOUVEAU templates/apt/autoremove.sh.tpl # NOUVEAU templates/apt/clean.sh.tpl # NOUVEAU server/ssh/client.ts # MODIF : +inactivityTimeoutMs (additif) server/services/execute.ts # MODIF : actions APT + buildAptExecutionResult + timeout ``` --- ## Task 1 : Diff dpkg (TDD) **Files:** Modify `server/services/aptParse.ts`, `server/services/aptParse.test.ts`. - [ ] **Step 1 : Test (échec attendu)** — ajouter à `aptParse.test.ts` ```ts import { parseDpkgList, buildAptExecutionResult } from "./aptParse.js"; const BEFORE = "libc6\t2.31-13\tamd64\noldpkg\t3.2-1\tamd64\nstable\t1.0\tamd64"; const AFTER = "libc6\t2.31-14\tamd64\nnewpkg\t1.0.0\tall\nstable\t1.0\tamd64"; describe("parseDpkgList", () => { it("indexe par package:arch", () => { const m = parseDpkgList("libc6\t2.31-13\tamd64"); expect(m["libc6:amd64"]).toEqual({ version: "2.31-13", arch: "amd64" }); }); }); describe("buildAptExecutionResult", () => { it("calcule le diff réel before/after", () => { const r = buildAptExecutionResult(BEFORE, AFTER, "REBOOT_REQUIRED=1"); expect(r.applied.find((c) => c.name === "libc6")).toMatchObject({ operation: "upgraded", fromVersion: "2.31-13", toVersion: "2.31-14" }); expect(r.installed.map((c) => c.name)).toEqual(["newpkg"]); expect(r.removed.map((c) => c.name)).toEqual(["oldpkg"]); expect(r.applied.some((c) => c.name === "stable")).toBe(false); // unchanged exclu expect(r.rebootRequiredAfterRun).toBe(true); }); }); ``` - [ ] **Step 2 : Lancer (échec)** — `rtk pnpm vitest run server/services/aptParse.test.ts` → FAIL. - [ ] **Step 3 : Étendre `server/services/aptParse.ts`** ```ts import type { AptChange, AptExecutionResult } from "@shared/types.js"; export function parseDpkgList(raw: string): Record { const out: Record = {}; for (const line of raw.split("\n")) { const parts = line.split("\t"); if (parts.length < 3) continue; const [name, version, arch] = [parts[0]!.trim(), parts[1]!.trim(), parts[2]!.trim()]; if (!name) continue; out[`${name}:${arch}`] = { version, arch }; } return out; } /** Diff dpkg réel before/after → AptExecutionResult (planned/held vides : portés par le snapshot). */ export function buildAptExecutionResult(beforeRaw: string, afterRaw: string, rebootRaw: string): AptExecutionResult { const before = parseDpkgList(beforeRaw); const after = parseDpkgList(afterRaw); const applied: AptChange[] = []; const installed: AptChange[] = []; const removed: AptChange[] = []; for (const key of Object.keys(after)) { const [name] = key.split(":"); const a = after[key]!; const b = before[key]; if (!b) { const change: AptChange = { name: name!, arch: a.arch, fromVersion: null, toVersion: a.version, operation: "installed" }; installed.push(change); applied.push(change); } else if (b.version !== a.version) { applied.push({ name: name!, arch: a.arch, fromVersion: b.version, toVersion: a.version, operation: "upgraded" }); } } for (const key of Object.keys(before)) { if (!after[key]) { const [name] = key.split(":"); const b = before[key]!; const change: AptChange = { name: name!, arch: b.arch, fromVersion: b.version, toVersion: null, operation: "removed" }; removed.push(change); applied.push(change); } } return { planned: [], applied, installed, removed, held: [], rebootRequiredAfterRun: /REBOOT_REQUIRED=1/.test(rebootRaw), }; } ``` - [ ] **Step 4 : Lancer (succès)** — `rtk pnpm vitest run server/services/aptParse.test.ts` → PASS. `rtk pnpm check` → 0 erreur. - [ ] **Step 5 : (pas de commit)** --- ## Task 2 : Templates APT (full-upgrade enrichi + upgrade/autoremove/clean) **Files:** Modify `templates/apt/full-upgrade.sh.tpl` ; Create `upgrade.sh.tpl`, `autoremove.sh.tpl`, `clean.sh.tpl`. - [ ] **Step 1 : Remplacer `templates/apt/full-upgrade.sh.tpl`** (ajoute DPKG_BEFORE/AFTER ; conserve REBOOT + EXIT que `execute.ts` parse déjà) ```sh #!/bin/sh export LC_ALL=C export DEBIAN_FRONTEND=noninteractive {{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}" {{/aptProxy}} echo "===SU:DPKG_BEFORE===" dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null echo "===SU:APT_FULLUPGRADE===" apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold dist-upgrade 2>&1 CODE=$? echo "===SU:DPKG_AFTER===" dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null echo "===SU:REBOOT===" if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi echo "===SU:EXIT=${CODE}===" ``` - [ ] **Step 2 : Créer `templates/apt/upgrade.sh.tpl`** ```sh #!/bin/sh export LC_ALL=C export DEBIAN_FRONTEND=noninteractive {{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}" {{/aptProxy}} echo "===SU:DPKG_BEFORE===" dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null echo "===SU:APT_UPGRADE===" apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade 2>&1 CODE=$? echo "===SU:DPKG_AFTER===" dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null echo "===SU:REBOOT===" if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi echo "===SU:EXIT=${CODE}===" ``` - [ ] **Step 3 : Créer `templates/apt/autoremove.sh.tpl`** ```sh #!/bin/sh export LC_ALL=C export DEBIAN_FRONTEND=noninteractive echo "===SU:APT_SIM_AUTOREMOVE===" apt-get -s -y autoremove 2>&1 echo "===SU:DPKG_BEFORE===" dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null echo "===SU:APT_AUTOREMOVE===" apt-get -y autoremove 2>&1 CODE=$? echo "===SU:DPKG_AFTER===" dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null echo "===SU:REBOOT===" if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi echo "===SU:EXIT=${CODE}===" ``` - [ ] **Step 4 : Créer `templates/apt/clean.sh.tpl`** ```sh #!/bin/sh export LC_ALL=C echo "===SU:APT_CLEAN===" BEFORE=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}') apt-get clean 2>&1 AFTER=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}') echo "FREED_BYTES=$((BEFORE - AFTER))" echo "===SU:EXIT=0===" ``` - [ ] **Step 5 :** `rtk pnpm vitest run server/templates/render.test.ts` reste vert. (pas de commit) --- ## Task 3 : Timeout d'inactivité SSH (additif) **Files:** Modify `server/ssh/client.ts`. - [ ] **Step 1 : Relire `server/ssh/client.ts`** (signatures `runScriptSudo`, `execStream`). - [ ] **Step 2 : Ajouter un paramètre optionnel `inactivityTimeoutMs`** (défaut 0 = désactivé) à `runScriptSudo` et `execStream`. Dans `execStream`, armer un timer réinitialisé à chaque `data`/`stderr data` ; à expiration, `stream.close()`/`conn.end()` et `reject(new Error("human_interaction_required: aucune sortie depuis " + (ms/1000) + "s"))`. ```ts export async function runScriptSudo( creds: SshCreds, script: string, onData: (chunk: string) => void, inactivityTimeoutMs = 0, ): Promise { const conn = await connect(creds); try { const b64 = Buffer.from(script, "utf8").toString("base64"); const cmd = `sudo -S -p '' sh -c "$(printf '%s' '${b64}' | base64 -d)"`; return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData, inactivityTimeoutMs); } finally { conn.end(); } } ``` Dans `execStream(conn, command, stdinData, onData, inactivityTimeoutMs = 0)` : après obtention du `stream`, ```ts let timer: NodeJS.Timeout | undefined; const arm = () => { if (!inactivityTimeoutMs) return; clearTimeout(timer); timer = setTimeout(() => { stream.close(); reject(new Error(`human_interaction_required: aucune sortie depuis ${Math.round(inactivityTimeoutMs / 1000)}s`)); }, inactivityTimeoutMs); }; arm(); ``` Réinitialiser `arm()` dans les handlers `data` et `stderr data` ; `clearTimeout(timer)` dans `close`. (Garder le `runPlain` existant inchangé : il appelle `execStream` sans le 5e arg ⇒ timeout 0.) - [ ] **Step 3 :** `rtk pnpm check` → 0 erreur. (Pas de test unitaire SSH ; vérif manuelle en live ultérieure.) (pas de commit) --- ## Task 4 : Brancher les actions APT dans `runAction` **Files:** Modify `server/services/execute.ts`. - [ ] **Step 1 : Relire `server/services/execute.ts`** (TEMPLATE_FOR, flux, update executions, blocs Phase 1 reports/artifacts/state/event). - [ ] **Step 2 : Étendre `TEMPLATE_FOR`** ```ts const TEMPLATE_FOR: Partial> = { apt_full_upgrade: "apt/full-upgrade.sh.tpl", apt_upgrade: "apt/upgrade.sh.tpl", apt_autoremove: "apt/autoremove.sh.tpl", apt_clean: "apt/clean.sh.tpl", reboot: "apt/reboot.sh.tpl", }; ``` (Adapter l'accès : `const rel = TEMPLATE_FOR[action]; if (!rel) throw new Error("Action sans template: " + action);`) - [ ] **Step 3 : Passer le timeout d'inactivité** pour les actions APT (pas pour reboot) : ```ts const inactivity = action === "reboot" ? 0 : 600000; const res = await runScriptSudo(getCreds(m), script, (c) => { raw += c; outputHub.publish(machineId, c); }, inactivity); ``` - [ ] **Step 4 : Construire `result.apt` (diff dpkg) pour les actions APT applicatives.** Après calcul de `raw` et avant l'écriture du rapport, ajouter : ```ts let aptResult: AptExecutionResult | undefined; if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) { aptResult = buildAptExecutionResult( extractSection(raw, "===SU:DPKG_BEFORE===", "==="), // jusqu'au prochain marqueur extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="), extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"), ); } ``` > ⚠️ `extractSection(raw, "===SU:DPKG_BEFORE===", "===")` : le 2ᵉ marqueur générique `"==="` capture jusqu'au prochain `===SU:...===`. Vérifier que `extractSection` (dans `refresh.ts`) coupe bien au 1ᵉʳ `"==="` rencontré ; sinon, utiliser le marqueur réel suivant (`"===SU:APT_FULLUPGRADE==="` / `"===SU:APT_UPGRADE==="` / `"===SU:APT_AUTOREMOVE==="`). **Préférer** le marqueur explicite : détecter lequel est présent. Implémentation robuste : ```ts const afterBeforeMarker = raw.includes("===SU:APT_FULLUPGRADE===") ? "===SU:APT_FULLUPGRADE===" : raw.includes("===SU:APT_UPGRADE===") ? "===SU:APT_UPGRADE===" : "===SU:APT_AUTOREMOVE==="; if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) { aptResult = buildAptExecutionResult( extractSection(raw, "===SU:DPKG_BEFORE===", afterBeforeMarker), extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="), extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"), ); } ``` - [ ] **Step 5 : Attacher `aptResult` au `ExecutionResult`** : dans la construction de `result`, ajouter `...(aptResult ? { apt: aptResult } : {})`. Importer en tête : `import { parseRebootRequired, extractSection } ...` (extractSection vient de `./refresh.js` — déjà importé) et `import { buildAptExecutionResult } from "./aptParse.js";` ainsi que `import type { AptExecutionResult } from "@shared/types.js";`. - [ ] **Step 6 : Vérifier** — `rtk pnpm check && rtk pnpm test` → 0 erreur, tests verts. (pas de commit) --- ## Task 5 : Vérification finale SJ-2 - [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert. - [ ] **Step 2 : Boot smoke** (DB jetable) → `/health` OK. Nettoyer. - [ ] **Step 3 :** Reporter. Vérif live ultérieure : `apt_full_upgrade` réel sur Debian → vérifier `result.apt.applied` (diff dpkg réel) + détection removed/held + comportement du timeout. **Ne pas committer.** --- ## Self-Review (couverture SJ-2) - Templates `upgrade`/`full-upgrade` enrichi/`autoremove`/`clean` → Task 2. ✓ - Capture `DPKG_BEFORE/AFTER` + diff réel (`AptExecutionResult`) → Task 1 + Task 4. ✓ - Timeout d'inactivité + `human_interaction_required` → Task 3 (additif, off par défaut) + Task 4 (600s pour APT). ✓ - Confirmations UI suppressions → hors périmètre (tâche 3) ; la donnée `removed[]` est exposée dans `result.apt`. ✓ (noté) - Non-régression : `apt_full_upgrade`/`reboot` jalon 1 conservés ; `runScriptSudo` rétro-compatible (timeout 0 par défaut) ; `ExecutionResult.apt` optionnel ; blocs Phase 1 préservés. ✓ Décision : `planned`/`held` laissés vides dans `AptExecutionResult` (portés par le snapshot SJ-1, pas re-simulés à l'exécution). `extractSection` utilisé avec marqueur explicite pour `DPKG_BEFORE`. Noms cohérents : `parseDpkgList`/`buildAptExecutionResult` (Task 1) utilisés Task 4 ; `inactivityTimeoutMs` (Task 3) passé Task 4.