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-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_upgradeetreboot(jalon 1) restent fonctionnels ; on enrichit sans casser le parsing exit/reboot existant deexecute.ts.runScriptSudo: nouveau paramètre optionnelinactivityTimeoutMs(défaut 0 = pas de timeout) ⇒refreshMachineet tout appelant existant inchangés de comportement.ExecutionResult.aptest 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
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
import type { AptChange, AptExecutionResult } from "@shared/types.js";
export function parseDpkgList(raw: string): Record<string, { version: string; arch: string }> {
const out: Record<string, { version: string; arch: string }> = {};
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 queexecute.tsparse déjà)
#!/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
#!/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
#!/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
#!/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.tsreste vert. (pas de commit)
Task 3 : Timeout d'inactivité SSH (additif)
Files: Modify server/ssh/client.ts.
-
Step 1 : Relire
server/ssh/client.ts(signaturesrunScriptSudo,execStream). -
Step 2 : Ajouter un paramètre optionnel
inactivityTimeoutMs(défaut 0 = désactivé) àrunScriptSudoetexecStream. DansexecStream, armer un timer réinitialisé à chaquedata/stderr data; à expiration,stream.close()/conn.end()etreject(new Error("human_interaction_required: aucune sortie depuis " + (ms/1000) + "s")).
export async function runScriptSudo(
creds: SshCreds,
script: string,
onData: (chunk: string) => void,
inactivityTimeoutMs = 0,
): Promise<RunResult> {
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,
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
const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
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) :
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 derawet avant l'écriture du rapport, ajouter :
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 queextractSection(dansrefresh.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 :
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
aptResultauExecutionResult: dans la construction deresult, ajouter...(aptResult ? { apt: aptResult } : {}). Importer en tête :import { parseRebootRequired, extractSection } ...(extractSection vient de./refresh.js— déjà importé) etimport { buildAptExecutionResult } from "./aptParse.js";ainsi queimport 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) →
/healthOK. Nettoyer. - Step 3 : Reporter. Vérif live ultérieure :
apt_full_upgraderéel sur Debian → vérifierresult.apt.applied(diff dpkg réel) + détection removed/held + comportement du timeout. Ne pas committer.
Self-Review (couverture SJ-2)
- Templates
upgrade/full-upgradeenrichi/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 dansresult.apt. ✓ (noté) - Non-régression :
apt_full_upgrade/rebootjalon 1 conservés ;runScriptSudorétro-compatible (timeout 0 par défaut) ;ExecutionResult.aptoptionnel ; 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.