Files
system_update/docs/superpowers/plans/2026-06-05-tache2-sj1-apt-update-analyze.md
gilles 0fbca06d3d docs: roadmap tâches 1.9-8 (briefs, gates de validation, designs tâche 2) + plans d'implémentation
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>
2026-06-05 19:50:25 +02:00

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.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
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) :

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 rendurtk 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 :
  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), update machines, upsertMachineState(machineId, deriveAptState(snapshot)), recordEvent(...), return snapshot;. deriveAptState lit snapshot.status/apt.count/apt.rebootRequired/checkedAt — inchangé.

  • Step 4 : Vérifierrtk 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) :

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|errorbuildAptSnapshotDetail. ✓
  • 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.