0fbca06d3d
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>
350 lines
15 KiB
Markdown
350 lines
15 KiB
Markdown
# 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.
|