Files
system_update/docs/superpowers/plans/2026-06-05-tache2-sj3-reboot-verifie.md
T
gillesandClaude Opus 4.8 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

253 lines
12 KiB
Markdown

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