# 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 via `apt/reboot.sh.tpl`, fire-and-forget). `reboot_verified` est une **nouvelle** action. - `ExecutionResult.reboot` est 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) ```sh #!/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.tpl` reste utilisé par l'action `reboot` (jalon 1) ET `reboot_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` ```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`** ```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 { 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 { 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 que `reboot`) ```ts reboot_verified: "apt/reboot.sh.tpl", ``` - [ ] **Step 3 : Après l'exécution du script (raw obtenu), lancer la vérification pour `reboot_verified`** et attacher `result.reboot`. Importer en tête : ```ts 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 : ```ts 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. > ⚠️ `verifyReboot` est **long** (jusqu'à plusieurs minutes). C'est acceptable : `runAction` est 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")` avant `verifyReboot`. - [ ] **Step 4 : Persister le délai adaptatif** (optionnel, simple) : après `verifyReboot`, si `rebootResult.lastRebootDurationSeconds`, l'écrire dans un event : ```ts 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) → `/health` OK. Nettoyer. - [ ] **Step 3 :** Reporter. **Vérif live indispensable** : `reboot_verified` réel sur une machine de test (la boucle réseau attente-coupure/retour + comparaison `boot_id` ne peut être validée qu'en conditions réelles). **Ne pas committer.** --- ## Self-Review (couverture SJ-3) - `apt/reboot.sh.tpl` capture `boot_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 `reboot` jalon 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).