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>
12 KiB
Tâche 2 — SJ-3 (reboot vérifié) — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
Goal: Ajouter l'action reboot_verified : capture du boot_id avant reboot, orchestration backend (attente de la coupure SSH, reconnexion avec délai adaptatif, relecture du boot_id), production d'un RebootResult (ok seulement si la machine revient ET le boot_id a changé). L'action reboot (jalon 1) reste inchangée.
Architecture: Référence docs/design/tache2/10-templates-apt.md §4.5 + 40-contrats-json.md §4 (RebootResult). Le template apt/reboot.sh.tpl est enrichi pour émettre ===SU:BOOT_ID_BEFORE===. Un module server/services/rebootVerify.ts contient : classifyReboot(...) (fonction pure, TDD) + verifyReboot(machineId) (orchestration réseau : poll runPlain jusqu'à coupure puis retour). execute.ts route l'action reboot_verified vers cette orchestration. Délai adaptatif stocké dans machine_state (réutilise la table Phase 1).
Tech Stack: TypeScript, ssh2, vitest.
Invariants
reboot(jalon 1) inchangé (toujours viaapt/reboot.sh.tpl, fire-and-forget).reboot_verifiedest une nouvelle action.ExecutionResult.rebootest optionnel (SJ-0) → rétro-compatible.- Pas de blocage indéfini : timeouts bornés (détection coupure ≤ 60 s ; retour machine ≤ 600 s par défaut).
- Tree partagé / WIP concurrent : ne toucher QUE
templates/apt/reboot.sh.tpl,server/services/rebootVerify.ts(+test),server/services/execute.ts. Ne pas committer.
File Structure
templates/apt/reboot.sh.tpl # MODIF : +===SU:BOOT_ID_BEFORE===
server/services/rebootVerify.ts # NOUVEAU : classifyReboot (pure) + verifyReboot (orchestration)
server/services/rebootVerify.test.ts # NOUVEAU : tests classifyReboot
server/services/execute.ts # MODIF : route action reboot_verified
Task 1 : Template reboot.sh.tpl (capture boot_id)
Files: Modify templates/apt/reboot.sh.tpl.
- Step 1 : Remplacer
templates/apt/reboot.sh.tpl(ajoute BOOT_ID_BEFORE ; conserve REBOOT_NOW)
#!/bin/sh
export LC_ALL=C
echo "===SU:BOOT_ID_BEFORE==="
cat /proc/sys/kernel/random/boot_id 2>/dev/null
echo "===SU:REBOOT_NOW==="
# Reboot différé pour laisser le canal SSH se fermer proprement.
nohup sh -c 'sleep 2; reboot' >/dev/null 2>&1 &
echo "reboot planifié"
- Step 2 : (pas de commit) —
templates/apt/reboot.sh.tplreste utilisé par l'actionreboot(jalon 1) ETreboot_verified.
Task 2 : classifyReboot (pure, TDD)
Files: Create server/services/rebootVerify.ts, server/services/rebootVerify.test.ts.
- Step 1 : Test (échec attendu) —
server/services/rebootVerify.test.ts
import { describe, it, expect } from "vitest";
import { classifyReboot, parseBootIdBefore } from "./rebootVerify.js";
describe("parseBootIdBefore", () => {
it("extrait le boot_id de la sortie du template", () => {
const raw = "===SU:BOOT_ID_BEFORE===\nabcd-1234\n===SU:REBOOT_NOW===\nreboot planifié";
expect(parseBootIdBefore(raw)).toBe("abcd-1234");
});
it("retourne null si absent", () => {
expect(parseBootIdBefore("rien")).toBeNull();
});
});
describe("classifyReboot", () => {
it("ok si la machine revient avec un boot_id différent", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: "B", wentDown: true, cameBack: true }).status).toBe("ok");
});
it("boot_id_unchanged si même boot_id", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: "A", wentDown: true, cameBack: true }).status).toBe("boot_id_unchanged");
});
it("ssh_never_went_down si la coupure n'a pas été observée", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: false, cameBack: false }).status).toBe("ssh_never_went_down");
});
it("machine_did_not_return si coupure mais pas de retour", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: true, cameBack: false }).status).toBe("machine_did_not_return");
});
});
-
Step 2 : Lancer (échec) —
rtk pnpm vitest run server/services/rebootVerify.test.ts→ FAIL. -
Step 3 : Implémenter le socle pur dans
server/services/rebootVerify.ts
// server/services/rebootVerify.ts
import { runPlain, type SshCreds } from "../ssh/client.js";
import type { RebootResult } from "@shared/types.js";
export function parseBootIdBefore(raw: string): string | null {
const s = raw.indexOf("===SU:BOOT_ID_BEFORE===");
if (s === -1) return null;
const from = s + "===SU:BOOT_ID_BEFORE===".length;
const e = raw.indexOf("===SU:REBOOT_NOW===", from);
const id = raw.slice(from, e === -1 ? undefined : e).trim();
return id || null;
}
export interface RebootSignals {
beforeBootId: string | null;
afterBootId: string | null;
wentDown: boolean;
cameBack: boolean;
}
/** Détermine le statut d'un reboot vérifié (fonction pure). */
export function classifyReboot(s: RebootSignals): { status: RebootResult["status"] } {
if (!s.wentDown) return { status: "ssh_never_went_down" };
if (!s.cameBack || s.afterBootId === null) return { status: "machine_did_not_return" };
if (s.beforeBootId !== null && s.afterBootId === s.beforeBootId) return { status: "boot_id_unchanged" };
return { status: "ok" };
}
async function readBootId(creds: SshCreds): Promise<string | null> {
try {
const res = await runPlain(creds, "cat /proc/sys/kernel/random/boot_id");
const id = res.stdout.trim();
return id || null;
} catch {
return null; // connexion impossible (machine down)
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export interface VerifyOptions {
beforeBootId: string | null;
requestedAt: string;
downTimeoutMs?: number; // détection de la coupure
upTimeoutMs?: number; // attente du retour
pollMs?: number;
}
/**
* Orchestration : attend la coupure SSH (machine qui reboote) puis le retour,
* relit le boot_id, et classe le résultat. Réseau ; non testé unitairement.
*/
export async function verifyReboot(creds: SshCreds, opt: VerifyOptions): Promise<RebootResult> {
const downTimeoutMs = opt.downTimeoutMs ?? 60000;
const upTimeoutMs = opt.upTimeoutMs ?? 600000;
const pollMs = opt.pollMs ?? 5000;
const t0 = Date.now();
// Phase A : attendre que la machine devienne injoignable.
let wentDown = false;
let sshWentDownAt: string | null = null;
while (Date.now() - t0 < downTimeoutMs) {
const id = await readBootId(creds);
if (id === null) { wentDown = true; sshWentDownAt = new Date().toISOString(); break; }
await sleep(pollMs);
}
// Phase B : attendre le retour (seulement si on a vu la coupure).
let cameBack = false;
let sshCameBackAt: string | null = null;
let afterBootId: string | null = null;
if (wentDown) {
const tB = Date.now();
while (Date.now() - tB < upTimeoutMs) {
const id = await readBootId(creds);
if (id !== null) { cameBack = true; sshCameBackAt = new Date().toISOString(); afterBootId = id; break; }
await sleep(pollMs);
}
}
const { status } = classifyReboot({ beforeBootId: opt.beforeBootId, afterBootId, wentDown, cameBack });
const waitedSeconds = Math.round((Date.now() - t0) / 1000);
return {
beforeBootId: opt.beforeBootId,
afterBootId,
requestedAt: opt.requestedAt,
sshWentDownAt,
sshCameBackAt,
waitedSeconds,
status,
lastRebootDurationSeconds: status === "ok" ? waitedSeconds : undefined,
nextRecommendedWaitSeconds: status === "ok" ? Math.round(waitedSeconds * 1.5) + 30 : undefined,
};
}
-
Step 4 : Lancer (succès) —
rtk pnpm vitest run server/services/rebootVerify.test.ts→ PASS (6).rtk pnpm check→ 0 erreur. -
Step 5 : (pas de commit)
Task 3 : Router l'action reboot_verified dans execute.ts
Files: Modify server/services/execute.ts.
-
Step 1 : Relire
server/services/execute.ts(TEMPLATE_FOR, flux, blocs Phase 1). -
Step 2 : Ajouter
reboot_verifiedàTEMPLATE_FOR(réutilise le même template quereboot)
reboot_verified: "apt/reboot.sh.tpl",
- Step 3 : Après l'exécution du script (raw obtenu), lancer la vérification pour
reboot_verifiedet attacherresult.reboot. Importer en tête :
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
import type { RebootResult } from "@shared/types.js";
Puis, après le bloc qui calcule status/raw et avant la construction de result (ou juste après, en enrichissant result), ajouter une branche :
let rebootResult: RebootResult | undefined;
if (action === "reboot_verified") {
const beforeBootId = parseBootIdBefore(raw);
rebootResult = await verifyReboot(getCreds(m), { beforeBootId, requestedAt: startedAt });
// Le statut de l'exécution suit la vérif : ok si reboot ok, sinon error.
if (rebootResult.status !== "ok") status = "error";
}
Puis dans la construction de result, ajouter ...(rebootResult ? { reboot: rebootResult } : {}) ; et conserver rebootRequiredAfterRun existant.
⚠️
verifyRebootest long (jusqu'à plusieurs minutes). C'est acceptable :runActionest déjà lancé en arrière-plan (la route POST renvoie 202). La sortie live reste streamée pendant l'attente n'est pas nécessaire ; on peut publier un message d'attente :outputHub.publish(machineId, "\n[reboot] attente du redémarrage...\n")avantverifyReboot.
- Step 4 : Persister le délai adaptatif (optionnel, simple) : après
verifyReboot, sirebootResult.lastRebootDurationSeconds, l'écrire dans un event :
if (rebootResult.status === "ok") {
recordEvent({ machineId, eventType: "reboot_verified", severity: "info", executionId,
message: `Reboot vérifié en ${rebootResult.waitedSeconds}s (boot_id changé)` });
}
(Le stockage en colonne dédiée machine_state peut venir plus tard ; l'event suffit au MVP.)
-
Step 5 : Vérifier —
rtk pnpm check && rtk pnpm test→ 0 erreur, tests verts. Les blocs Phase 1 (executions/reports/rawArtifacts/state/event) restent intacts ;reboot(jalon 1) inchangé. -
Step 6 : (pas de commit)
Task 4 : Vérification finale SJ-3
- 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 indispensable :
reboot_verifiedréel sur une machine de test (la boucle réseau attente-coupure/retour + comparaisonboot_idne peut être validée qu'en conditions réelles). Ne pas committer.
Self-Review (couverture SJ-3)
apt/reboot.sh.tplcaptureboot_id→ Task 1. ✓- Orchestration backend (attente coupure → reconnexion délai adaptatif → relecture boot_id) → Task 2 (
verifyReboot). ✓ RebootResult+ statuts (ok/ssh_never_went_down/machine_did_not_return/boot_id_unchanged/timeout) →classifyReboot(TDD) +verifyReboot. ✓- Délai adaptatif
lastRebootDurationSeconds→nextRecommendedWaitSeconds→verifyReboot. ✓ - Conserve l'action
rebootjalon 1 → Task 3 (nouvelle action distincte). ✓
Décision : la boucle réseau utilise des timeouts bornés (down ≤ 60 s, up ≤ 600 s, poll 5 s) ; seule classifyReboot (+parseBootIdBefore) est testée unitairement, l'orchestration est validée en live. timeout (statut) est couvert par machine_did_not_return quand le retour n'arrive pas dans upTimeoutMs (mêmes conséquences ; un raffinement timeout explicite est notable mais non bloquant au MVP).