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>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# Jalon 2 — Polish design system — Implementation Plan
|
||||
|
||||
> **⚠️ STATUT (2026-06-05) : ABSORBÉ PAR LA TÂCHE 3.** La roadmap `liste_taches.md` / `coherence_taches.md` regroupe tout le frontend (layout, tuiles, volet Hermes, terminal, paramètres, thème, status bar, icônes) dans la **tâche 3 (design frontend)**, gate `validation_tache3.md`. Ce plan jalon-2 reste valide comme **matériau d'implémentation du polish** : le wiring DS (exports ESM + Font Awesome + polices, Tasks 1-4) est **déjà commité** et acquis ; les Tasks 5-12 (Header, StatusBar, refonte MachineTile/AddMachineModal/TerminalPanel/Dashboard/App) seront **implémentées plus tard dans le cadre de la tâche 3**, après validation de son design. Ne pas exécuter ce plan isolément.
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Refondre l'UI existante avec les composants du design system Gruvbox (Button, IconButton, StatusLed, Popup), brancher Font Awesome + les polices en offline, ajouter un header (titre + ajout + bascule thème) et une status bar tmux, et rendre le terminal non ambigu entre machines.
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
# Tâche 1.9 — Phase 1 (schéma BDD socle) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implémenter la Phase 1 du schéma BDD cible (`tache1.9.md §14`) : étendre `machines/snapshots/executions`, créer les tables socle (`machine_state`, `machine_hardware`, `machine_metrics_latest`, `machine_events`, `important_messages`, `reports`, `raw_artifacts`), et alimenter l'état dérivé + la timeline lors des refresh/exécutions existants.
|
||||
|
||||
**Architecture:** Extension additive (rétro-compatible) du schéma Drizzle/SQLite. Migration générée par drizzle-kit. Un service `machineState` dérive l'état courant d'une machine depuis un snapshot/exécution et l'« upsert » dans `machine_state` ; `refreshMachine` et `runAction` (existants) sont enrichis pour peupler `machine_state`, `machine_events`, et (pour les exécutions) `reports` + `raw_artifacts`, ainsi que les nouveaux champs `kind/schema_version/important_json` des snapshots/exécutions. **Aucune modification de l'API ni du frontend** (réservé tâches 3/5).
|
||||
|
||||
**Tech Stack:** Drizzle ORM, better-sqlite3, drizzle-kit, vitest.
|
||||
|
||||
---
|
||||
|
||||
## Contexte & invariants
|
||||
- État actuel : `server/db/schema.ts` contient `machines`, `snapshots`, `executions` (jalon 1, en prod). Voir le fichier.
|
||||
- **Rétro-compatibilité stricte** : on AJOUTE des colonnes/tables ; on ne renomme ni ne supprime rien. En particulier on conserve `snapshots.checked_at` (le design tache1.9 le nomme `created_at`, mais le code jalon 1 `refresh.ts` utilise `checkedAt` — on ne casse pas).
|
||||
- Les nouveaux champs sont nullable ou ont une valeur par défaut, pour que les lignes du jalon 1 restent valides.
|
||||
- `payload_json` / `result_json` restent la vérité canonique ; `machine_state` n'est qu'un cache dérivé pour l'UI (jamais source de vérité métier).
|
||||
- Pas de FK vers des tables non encore créées (jobs/action_requests/schedules = phases ultérieures) : `running_job_id`, `request_id`, `job_id` sont de simples colonnes `text` nullable.
|
||||
- Ne pas committer (l'utilisateur gère les commits en fin de parcours). Les étapes « Commit » du template sont **remplacées par une vérification** ; ne PAS exécuter `git commit`.
|
||||
|
||||
> **Note exécution** : ce plan se construit sur l'état courant du working tree (qui contient du WIP non commité : feature `capabilities`, scaffold Rust). Ne pas annuler ce WIP. Les fichiers touchés ici (`server/db/*`, `server/services/*`) ne chevauchent pas le WIP `capabilities`/frontend.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
server/db/
|
||||
├─ schema.ts # MODIF : +colonnes machines/snapshots/executions, +7 tables
|
||||
├─ migrations/ # +1 migration générée (drizzle-kit)
|
||||
└─ schema.test.ts # NOUVEAU : test que la migration applique le schéma cible
|
||||
server/services/
|
||||
├─ machineState.ts # NOUVEAU : dériver + upsert machine_state, insert events
|
||||
├─ machineState.test.ts # NOUVEAU : tests purs de dérivation
|
||||
├─ refresh.ts # MODIF : peupler snapshot.kind/schema_version/important_json + machine_state + event
|
||||
└─ execute.ts # MODIF : champs executions + machine_state + event + reports + raw_artifacts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Étendre le schéma Drizzle + migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/db/schema.ts`
|
||||
- Create: migration sous `server/db/migrations/` (générée)
|
||||
- Create: `server/db/schema.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Remplacer le contenu de `server/db/schema.ts`**
|
||||
|
||||
> **⚠️ Tree déplacé** : `schema.ts` contient déjà la table `apiClients` (WIP api_clients, migration `0001_api_clients.sql`). Le contenu ci-dessous **préserve `apiClients`** (et l'import `uniqueIndex`). NE PAS supprimer `apiClients`. La migration générée à l'étape suivante sera donc `0002_*` (et non `0001`).
|
||||
|
||||
```ts
|
||||
// server/db/schema.ts
|
||||
import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const machines = sqliteTable("machines", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
hostname: text("hostname").notNull(),
|
||||
port: integer("port").notNull().default(22),
|
||||
osFamily: text("os_family").notNull().default("unknown"),
|
||||
osVersion: text("os_version"),
|
||||
osCodename: text("os_codename"),
|
||||
arch: text("arch"),
|
||||
machineKind: text("machine_kind"), // physical | vm | proxmox_host | lxc | raspberry_pi | workstation | unknown
|
||||
virtualization: text("virtualization"), // none | qemu | kvm | lxc | docker | vmware | ...
|
||||
hardwareProfile: text("hardware_profile"), // generic_vm | baremetal_server | raspberry_pi | gpu_server | proxmox_host | ...
|
||||
username: text("username").notNull(),
|
||||
encPassword: text("enc_password").notNull(),
|
||||
encSudoPassword: text("enc_sudo_password"),
|
||||
aptProxyMode: text("apt_proxy_mode").notNull().default("direct"),
|
||||
aptProxyUrl: text("apt_proxy_url"),
|
||||
status: text("status").notNull().default("unknown"),
|
||||
lastCheckedAt: text("last_checked_at"),
|
||||
lastSeenAt: text("last_seen_at"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at"),
|
||||
deletedAt: text("deleted_at"),
|
||||
});
|
||||
|
||||
export const snapshots = sqliteTable("snapshots", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").notNull().default("apt_update_analyze"),
|
||||
schemaVersion: integer("schema_version").notNull().default(1),
|
||||
checkedAt: text("checked_at").notNull(),
|
||||
status: text("status").notNull(),
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
importantJson: text("important_json"),
|
||||
rawLogPath: text("raw_log_path"),
|
||||
rawArtifactId: text("raw_artifact_id"),
|
||||
sourceJobId: text("source_job_id"),
|
||||
});
|
||||
|
||||
export const executions = sqliteTable("executions", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
|
||||
action: text("action").notNull(),
|
||||
mode: text("mode").notNull().default("manual"),
|
||||
schemaVersion: integer("schema_version").notNull().default(1),
|
||||
startedAt: text("started_at").notNull(),
|
||||
finishedAt: text("finished_at"),
|
||||
status: text("status").notNull(),
|
||||
requestId: text("request_id"),
|
||||
jobId: text("job_id"),
|
||||
resultJson: text("result_json"),
|
||||
importantJson: text("important_json"),
|
||||
reportPath: text("report_path"),
|
||||
rawLogPath: text("raw_log_path"),
|
||||
reportId: text("report_id"),
|
||||
exitCode: integer("exit_code"),
|
||||
errorKind: text("error_kind"),
|
||||
errorMessage: text("error_message"),
|
||||
});
|
||||
|
||||
export const machineState = sqliteTable("machine_state", {
|
||||
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
|
||||
status: text("status").notNull(),
|
||||
aptStatus: text("apt_status"),
|
||||
aptUpdatesCount: integer("apt_updates_count").notNull().default(0),
|
||||
aptRebootRequired: integer("apt_reboot_required").notNull().default(0),
|
||||
aptLastAnalyzeAt: text("apt_last_analyze_at"),
|
||||
dockerStatus: text("docker_status"),
|
||||
dockerInstalled: integer("docker_installed").notNull().default(0),
|
||||
dockerStacksCount: integer("docker_stacks_count").notNull().default(0),
|
||||
dockerUpdatesCount: integer("docker_updates_count").notNull().default(0),
|
||||
dockerPruneAvailable: integer("docker_prune_available").notNull().default(0),
|
||||
postInstallStatus: text("post_install_status"),
|
||||
metricsLastCollectedAt: text("metrics_last_collected_at"),
|
||||
cpuLoad1: real("cpu_load1"),
|
||||
memoryUsedPercent: real("memory_used_percent"),
|
||||
rootUsedPercent: real("root_used_percent"),
|
||||
diskWarningsCount: integer("disk_warnings_count").notNull().default(0),
|
||||
hardwareWarningsCount: integer("hardware_warnings_count").notNull().default(0),
|
||||
runningJobId: text("running_job_id"),
|
||||
lastErrorKind: text("last_error_kind"),
|
||||
lastErrorMessage: text("last_error_message"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const machineHardware = sqliteTable("machine_hardware", {
|
||||
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
|
||||
probeSnapshotId: text("probe_snapshot_id"),
|
||||
cpuModel: text("cpu_model"),
|
||||
cpuCores: integer("cpu_cores"),
|
||||
memoryBytes: integer("memory_bytes"),
|
||||
gpusJson: text("gpus_json"),
|
||||
disksJson: text("disks_json"),
|
||||
networkJson: text("network_json"),
|
||||
firmwareJson: text("firmware_json"),
|
||||
driverJson: text("driver_json"),
|
||||
warningsJson: text("warnings_json"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const machineMetricsLatest = sqliteTable("machine_metrics_latest", {
|
||||
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
|
||||
snapshotId: text("snapshot_id"),
|
||||
collectedAt: text("collected_at").notNull(),
|
||||
cpuLoad1: real("cpu_load1"),
|
||||
cpuLoad5: real("cpu_load5"),
|
||||
cpuCores: integer("cpu_cores"),
|
||||
memoryTotalBytes: integer("memory_total_bytes"),
|
||||
memoryUsedBytes: integer("memory_used_bytes"),
|
||||
memoryAvailableBytes: integer("memory_available_bytes"),
|
||||
memoryUsedPercent: real("memory_used_percent"),
|
||||
filesystemsJson: text("filesystems_json"),
|
||||
rootUsedPercent: real("root_used_percent"),
|
||||
warningsJson: text("warnings_json"),
|
||||
});
|
||||
|
||||
export const machineEvents = sqliteTable("machine_events", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
|
||||
eventType: text("event_type").notNull(),
|
||||
severity: text("severity").notNull(), // info | warning | error
|
||||
createdAt: text("created_at").notNull(),
|
||||
actorType: text("actor_type"), // user | system | schedule | hermes
|
||||
actorId: text("actor_id"),
|
||||
snapshotId: text("snapshot_id"),
|
||||
executionId: text("execution_id"),
|
||||
jobId: text("job_id"),
|
||||
message: text("message"),
|
||||
payloadJson: text("payload_json"),
|
||||
});
|
||||
|
||||
export const importantMessages = sqliteTable("important_messages", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
|
||||
source: text("source").notNull(), // apt | docker | post_install | ssh | system
|
||||
category: text("category").notNull(), // error | warning | future_major_change | ...
|
||||
severity: text("severity").notNull(),
|
||||
packageName: text("package_name"),
|
||||
component: text("component"),
|
||||
message: text("message").notNull(),
|
||||
rawLineRef: text("raw_line_ref"),
|
||||
snapshotId: text("snapshot_id"),
|
||||
executionId: text("execution_id"),
|
||||
firstSeenAt: text("first_seen_at").notNull(),
|
||||
lastSeenAt: text("last_seen_at").notNull(),
|
||||
acknowledged: integer("acknowledged").notNull().default(0),
|
||||
acknowledgedAt: text("acknowledged_at"),
|
||||
acknowledgedBy: text("acknowledged_by"),
|
||||
payloadJson: text("payload_json"),
|
||||
});
|
||||
|
||||
export const reports = sqliteTable("reports", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
|
||||
executionId: text("execution_id"),
|
||||
kind: text("kind").notNull(), // machine | global | cleanup | hermes
|
||||
title: text("title").notNull(),
|
||||
path: text("path").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
pinned: integer("pinned").notNull().default(0),
|
||||
summaryJson: text("summary_json"),
|
||||
});
|
||||
|
||||
export const rawArtifacts = sqliteTable("raw_artifacts", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").notNull(), // raw_log | rendered_template | export | screenshot
|
||||
path: text("path").notNull(),
|
||||
bytes: integer("bytes"),
|
||||
sha256: text("sha256"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
expiresAt: text("expires_at"),
|
||||
pinned: integer("pinned").notNull().default(0),
|
||||
redacted: integer("redacted").notNull().default(1),
|
||||
retentionPolicy: text("retention_policy"), // default | failed | pinned | short
|
||||
deletedAt: text("deleted_at"),
|
||||
deleteReason: text("delete_reason"),
|
||||
metadataJson: text("metadata_json"),
|
||||
});
|
||||
|
||||
// --- Préexistant (WIP api_clients) : NE PAS supprimer ---
|
||||
export const apiClients = sqliteTable(
|
||||
"api_clients",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
tokenPrefix: text("token_prefix").notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
scopesJson: text("scopes_json").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
revokedAt: text("revoked_at"),
|
||||
},
|
||||
(table) => ({
|
||||
tokenHashIdx: uniqueIndex("api_clients_token_hash_unique").on(table.tokenHash),
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
> Avant d'écrire : relire l'état RÉEL de `server/db/schema.ts` (`rtk read server/db/schema.ts`). Si `apiClients` y a évolué (colonnes différentes), reprendre sa définition à l'identique plutôt que celle ci-dessus, pour ne pas régresser le WIP. N'ajouter que les colonnes étendues + les 7 nouvelles tables.
|
||||
|
||||
- [ ] **Step 2 : Générer la migration**
|
||||
|
||||
Run: `rtk pnpm db:generate`
|
||||
Expected: un nouveau fichier `server/db/migrations/0002_*.sql` est créé (ALTER TABLE machines/snapshots/executions + CREATE TABLE des 7 nouvelles tables). La migration `0001_api_clients.sql` reste intacte. Aucune erreur drizzle-kit, aucun DROP de `api_clients`.
|
||||
|
||||
- [ ] **Step 3 : Écrire le test de migration `server/db/schema.test.ts`**
|
||||
|
||||
```ts
|
||||
// server/db/schema.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
function freshMigratedDb() {
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = drizzle(sqlite);
|
||||
migrate(db, { migrationsFolder: "./server/db/migrations" });
|
||||
return sqlite;
|
||||
}
|
||||
|
||||
function tableNames(sqlite: Database.Database): string[] {
|
||||
return sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r: any) => r.name);
|
||||
}
|
||||
function columnNames(sqlite: Database.Database, table: string): string[] {
|
||||
return sqlite.prepare(`PRAGMA table_info(${table})`).all().map((r: any) => r.name);
|
||||
}
|
||||
|
||||
describe("schéma Phase 1", () => {
|
||||
it("crée les tables socle", () => {
|
||||
const sqlite = freshMigratedDb();
|
||||
const tables = tableNames(sqlite);
|
||||
for (const t of [
|
||||
"machines", "snapshots", "executions",
|
||||
"machine_state", "machine_hardware", "machine_metrics_latest",
|
||||
"machine_events", "important_messages", "reports", "raw_artifacts",
|
||||
]) {
|
||||
expect(tables, `table ${t}`).toContain(t);
|
||||
}
|
||||
});
|
||||
|
||||
it("ajoute les colonnes étendues sans casser l'existant", () => {
|
||||
const sqlite = freshMigratedDb();
|
||||
expect(columnNames(sqlite, "machines")).toEqual(
|
||||
expect.arrayContaining(["machine_kind", "virtualization", "hardware_profile", "os_version", "updated_at"]),
|
||||
);
|
||||
expect(columnNames(sqlite, "snapshots")).toEqual(
|
||||
expect.arrayContaining(["kind", "schema_version", "important_json"]),
|
||||
);
|
||||
expect(columnNames(sqlite, "executions")).toEqual(
|
||||
expect.arrayContaining(["schema_version", "error_kind", "error_message", "exit_code"]),
|
||||
);
|
||||
// colonnes jalon 1 conservées
|
||||
expect(columnNames(sqlite, "snapshots")).toContain("checked_at");
|
||||
expect(columnNames(sqlite, "machines")).toContain("enc_password");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test**
|
||||
|
||||
Run: `rtk pnpm vitest run server/db/schema.test.ts`
|
||||
Expected: PASS (2 tests). Si une colonne attendue manque, corriger `schema.ts` et régénérer la migration (`rtk pnpm db:generate`) — ne pas modifier le test.
|
||||
|
||||
- [ ] **Step 5 : Vérifier la compilation + non-régression**
|
||||
|
||||
Run: `rtk pnpm check && rtk pnpm test`
|
||||
Expected: 0 erreur TS ; toute la suite verte (les tests existants ne doivent pas casser).
|
||||
|
||||
- [ ] **Step 6 : (pas de commit — vérification seulement)**
|
||||
|
||||
Vérifier `git status` : seuls `server/db/schema.ts`, `server/db/migrations/0001_*.sql`, `server/db/schema.test.ts` ajoutés à la liste des modifs. Ne PAS committer.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Service `machineState` (dérivation + upsert + events)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/services/machineState.ts`, `server/services/machineState.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test (échec attendu)**
|
||||
|
||||
```ts
|
||||
// server/services/machineState.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { deriveAptState } from "./machineState.js";
|
||||
import type { UpdateSnapshot } from "@shared/types.js";
|
||||
|
||||
const snap: UpdateSnapshot = {
|
||||
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
|
||||
checkedAt: "2026-06-05T10:00:00Z", status: "updates_available",
|
||||
apt: { enabled: true, count: 3, rebootRequired: true, packages: [] },
|
||||
};
|
||||
|
||||
describe("deriveAptState", () => {
|
||||
it("dérive le bloc APT de machine_state depuis un snapshot", () => {
|
||||
expect(deriveAptState(snap)).toEqual({
|
||||
status: "updates_available",
|
||||
aptStatus: "updates_available",
|
||||
aptUpdatesCount: 3,
|
||||
aptRebootRequired: 1,
|
||||
aptLastAnalyzeAt: "2026-06-05T10:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("met rebootRequired à 0 quand absent", () => {
|
||||
const s = { ...snap, status: "ok" as const, apt: { ...snap.apt, count: 0, rebootRequired: false } };
|
||||
expect(deriveAptState(s)).toMatchObject({ aptUpdatesCount: 0, aptRebootRequired: 0, status: "ok" });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer (échec)**
|
||||
|
||||
Run: `rtk pnpm vitest run server/services/machineState.test.ts`
|
||||
Expected: FAIL — module introuvable.
|
||||
|
||||
- [ ] **Step 3 : Implémenter `server/services/machineState.ts`**
|
||||
|
||||
```ts
|
||||
// server/services/machineState.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import type { UpdateSnapshot } from "@shared/types.js";
|
||||
|
||||
export interface AptDerivedState {
|
||||
status: string;
|
||||
aptStatus: string;
|
||||
aptUpdatesCount: number;
|
||||
aptRebootRequired: number;
|
||||
aptLastAnalyzeAt: string;
|
||||
}
|
||||
|
||||
/** Dérive le bloc APT de l'état courant depuis un snapshot (fonction pure). */
|
||||
export function deriveAptState(snapshot: UpdateSnapshot): AptDerivedState {
|
||||
return {
|
||||
status: snapshot.status,
|
||||
aptStatus: snapshot.status,
|
||||
aptUpdatesCount: snapshot.apt.count,
|
||||
aptRebootRequired: snapshot.apt.rebootRequired ? 1 : 0,
|
||||
aptLastAnalyzeAt: snapshot.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
type MachineStateInsert = typeof schema.machineState.$inferInsert;
|
||||
|
||||
/** Insère ou met à jour les champs fournis de machine_state pour une machine. */
|
||||
export function upsertMachineState(
|
||||
machineId: string,
|
||||
fields: Partial<Omit<MachineStateInsert, "machineId" | "updatedAt" | "status">> & { status: string },
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(schema.machineState)
|
||||
.values({ machineId, updatedAt: now, ...fields })
|
||||
.onConflictDoUpdate({
|
||||
target: schema.machineState.machineId,
|
||||
set: { ...fields, updatedAt: now },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Ajoute une ligne à la timeline machine_events. */
|
||||
export function recordEvent(input: {
|
||||
machineId: string;
|
||||
eventType: string;
|
||||
severity: "info" | "warning" | "error";
|
||||
actorType?: string;
|
||||
snapshotId?: string;
|
||||
executionId?: string;
|
||||
message?: string;
|
||||
}): void {
|
||||
db.insert(schema.machineEvents).values({
|
||||
id: randomUUID(),
|
||||
machineId: input.machineId,
|
||||
eventType: input.eventType,
|
||||
severity: input.severity,
|
||||
createdAt: new Date().toISOString(),
|
||||
actorType: input.actorType ?? "system",
|
||||
snapshotId: input.snapshotId,
|
||||
executionId: input.executionId,
|
||||
message: input.message,
|
||||
}).run();
|
||||
}
|
||||
|
||||
/** Utilitaire interne réservé aux migrations/tests éventuels. */
|
||||
export const _internal = { sql };
|
||||
```
|
||||
|
||||
> Note : `_internal` exporte `sql` pour rester explicite sur l'import drizzle ; supprime-le si lint le signale inutilisé et retire l'import `sql` correspondant.
|
||||
|
||||
- [ ] **Step 4 : Lancer (succès)**
|
||||
|
||||
Run: `rtk pnpm vitest run server/services/machineState.test.ts`
|
||||
Expected: PASS (2 tests). `deriveAptState` est pure et n'importe pas de DB au moment du test — mais le module importe `../db/client.js`. Si l'import de `db/client` fait échouer le test en environnement node (chargement better-sqlite3), refactorer en isolant la fonction pure dans le même fichier sans exécuter de requête à l'import (c'est déjà le cas : aucune requête n'est lancée à l'import). Le test n'appelle que `deriveAptState`.
|
||||
|
||||
- [ ] **Step 5 : Vérifier**
|
||||
|
||||
Run: `rtk pnpm check`
|
||||
Expected: 0 erreur.
|
||||
|
||||
- [ ] **Step 6 : (pas de commit)**
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Peupler l'état + la timeline dans `refreshMachine`
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/services/refresh.ts`
|
||||
|
||||
- [ ] **Step 1 : Lire l'état actuel de `refresh.ts`**
|
||||
|
||||
Run: `rtk read server/services/refresh.ts`
|
||||
Repère : la construction de `snapshot`, l'`insert` dans `schema.snapshots`, et l'`update` de `machines.status`.
|
||||
|
||||
- [ ] **Step 2 : Enrichir l'insertion du snapshot (kind/schema_version/important_json)**
|
||||
|
||||
Dans `refreshMachine`, remplacer l'insertion actuelle du snapshot par :
|
||||
|
||||
```ts
|
||||
const snapshotId = randomUUID();
|
||||
db.insert(schema.snapshots).values({
|
||||
id: snapshotId,
|
||||
machineId,
|
||||
kind: "apt_update_analyze",
|
||||
schemaVersion: 1,
|
||||
checkedAt,
|
||||
status,
|
||||
payloadJson: JSON.stringify(snapshot),
|
||||
importantJson: JSON.stringify(snapshot.rawHints?.logImportantLines ?? []),
|
||||
}).run();
|
||||
```
|
||||
|
||||
(Le `randomUUID` est déjà importé dans `refresh.ts`.)
|
||||
|
||||
- [ ] **Step 3 : Mettre à jour `machine_state` + event après le snapshot**
|
||||
|
||||
Ajouter, juste après l'`update` de `machines` (status/lastCheckedAt), et après avoir importé en tête de fichier
|
||||
`import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";` :
|
||||
|
||||
```ts
|
||||
upsertMachineState(machineId, deriveAptState(snapshot));
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: "apt_refresh",
|
||||
severity: status === "error" ? "error" : "info",
|
||||
snapshotId,
|
||||
message: `Refresh APT : ${snapshot.apt.count} mise(s) à jour`,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Vérifier compilation + tests**
|
||||
|
||||
Run: `rtk pnpm check && rtk pnpm vitest run server/services/refresh.test.ts`
|
||||
Expected: 0 erreur TS ; le test existant `extractSection` reste vert (il n'importe pas la DB grâce au mock en place).
|
||||
|
||||
- [ ] **Step 5 : (pas de commit)**
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Enrichir `runAction` (champs executions + state + event + reports + raw_artifacts)
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/services/execute.ts`
|
||||
|
||||
- [ ] **Step 1 : Lire l'état actuel de `execute.ts`**
|
||||
|
||||
Run: `rtk read server/services/execute.ts`
|
||||
Repère : l'`insert` initial dans `executions`, le bloc d'archivage (`writeFileSync` du log + rapport), l'`update` final de `executions` et de `machines`.
|
||||
|
||||
- [ ] **Step 2 : Importer les helpers**
|
||||
|
||||
En tête de `execute.ts`, ajouter :
|
||||
```ts
|
||||
import { randomUUID } from "node:crypto"; // déjà présent — ne pas dupliquer
|
||||
import { statSync } from "node:fs";
|
||||
import { upsertMachineState, recordEvent } from "./machineState.js";
|
||||
```
|
||||
(Si `randomUUID` est déjà importé, n'ajouter que `statSync` et la ligne `machineState`.)
|
||||
|
||||
- [ ] **Step 3 : Mettre `running_job_id`/status dans machine_state au démarrage**
|
||||
|
||||
Juste après l'`update` initial de `machines` en `status: "running"` et l'`insert` de l'exécution (status `running`), ajouter :
|
||||
```ts
|
||||
upsertMachineState(machineId, { status: "running", runningJobId: executionId });
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Enrichir l'`update` final de l'exécution**
|
||||
|
||||
Remplacer l'`update` final de `schema.executions` par (ajout `schemaVersion`, `importantJson`, `exitCode`, `errorKind`, `errorMessage`, `reportId`) :
|
||||
|
||||
```ts
|
||||
const reportId = randomUUID();
|
||||
const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw);
|
||||
db.update(schema.executions).set({
|
||||
finishedAt,
|
||||
status,
|
||||
schemaVersion: 1,
|
||||
resultJson: JSON.stringify(result),
|
||||
importantJson: JSON.stringify(result.importantLogLines),
|
||||
reportPath,
|
||||
rawLogPath,
|
||||
reportId,
|
||||
exitCode: exitMatch ? Number(exitMatch[1]) : null,
|
||||
errorKind: status === "error" ? "execution_failed" : null,
|
||||
errorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
|
||||
}).where(eq(schema.executions.id, executionId)).run();
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Insérer `reports` + `raw_artifacts` + state + event**
|
||||
|
||||
Juste après l'`update` final de `machines`, ajouter :
|
||||
```ts
|
||||
db.insert(schema.reports).values({
|
||||
id: reportId,
|
||||
machineId,
|
||||
executionId,
|
||||
kind: "machine",
|
||||
title: `${m.name} — ${action}`,
|
||||
path: reportPath,
|
||||
createdAt: finishedAt,
|
||||
}).run();
|
||||
|
||||
db.insert(schema.rawArtifacts).values({
|
||||
id: randomUUID(),
|
||||
machineId,
|
||||
kind: "raw_log",
|
||||
path: rawLogPath,
|
||||
bytes: statSync(rawLogPath).size,
|
||||
createdAt: finishedAt,
|
||||
retentionPolicy: status === "error" ? "failed" : "default",
|
||||
}).run();
|
||||
|
||||
upsertMachineState(machineId, {
|
||||
status: status === "error" ? "error" : "unknown",
|
||||
runningJobId: null,
|
||||
lastErrorKind: status === "error" ? "execution_failed" : null,
|
||||
lastErrorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
|
||||
});
|
||||
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: `action_${action}`,
|
||||
severity: status === "error" ? "error" : status === "warning" ? "warning" : "info",
|
||||
executionId,
|
||||
message: `Action ${action} : ${status}`,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Vérifier compilation + tests**
|
||||
|
||||
Run: `rtk pnpm check && rtk pnpm test`
|
||||
Expected: 0 erreur TS ; suite complète verte.
|
||||
|
||||
- [ ] **Step 7 : (pas de commit)**
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Vérification finale Phase 1
|
||||
|
||||
**Files:** aucun (vérification).
|
||||
|
||||
- [ ] **Step 1 : Suite + build**
|
||||
|
||||
Run: `rtk pnpm check && rtk pnpm test && rtk pnpm build`
|
||||
Expected: 0 erreur TS ; tests verts (jalon 1 + schema migration + machineState + helpers existants) ; `dist/index.js` + `dist/client` produits.
|
||||
|
||||
- [ ] **Step 2 : Démarrage runtime + migration appliquée**
|
||||
|
||||
Run (clé jetable, DB jetable) :
|
||||
```bash
|
||||
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/phase1-check.db SU_REPORTS_DIR=./data/phase1-reports
|
||||
node dist/index.js > ./data/phase1.log 2>&1 &
|
||||
sleep 3
|
||||
curl -s localhost:8787/health
|
||||
sqlite3 ./data/phase1-check.db ".tables" 2>/dev/null || echo "(sqlite3 absent — vérifier via le test de migration)"
|
||||
kill %1 2>/dev/null
|
||||
rm -rf ./data/phase1-check.db* ./data/phase1-reports ./data/phase1.log
|
||||
```
|
||||
Expected: `{"ok":true}` et la liste des tables inclut `machine_state`, `machine_events`, `reports`, `raw_artifacts`, etc. (migration appliquée au boot via `runMigrations()`).
|
||||
|
||||
- [ ] **Step 3 : Synthèse à l'utilisateur**
|
||||
|
||||
Reporter : tables/colonnes ajoutées, `machine_state`/`machine_events`/`reports`/`raw_artifacts` peuplés lors des refresh/exécutions, non-régression confirmée. **Ne pas committer** (l'utilisateur gère les commits en fin de parcours).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (couverture tache1.9 §14 Phase 1)
|
||||
|
||||
- machine_state → Task 1 (table) + Task 2/3/4 (peuplement). ✓
|
||||
- machine_kind/virtualization/hardware_profile dans machines → Task 1. ✓
|
||||
- machine_hardware → Task 1 (table ; producteur = tâche 4, hors Phase 1). ✓
|
||||
- machine_metrics_latest → Task 1 (table ; producteur = tâche 4). ✓
|
||||
- machine_events → Task 1 + Task 3/4 (peuplement). ✓
|
||||
- important_messages → Task 1 (table ; peuplement fin = tâche 5/7, l'`important_json` du snapshot/exécution est déjà capturé). ✓
|
||||
- reports → Task 1 + Task 4 (peuplement depuis le rapport déjà écrit). ✓
|
||||
- raw_artifacts → Task 1 + Task 4 (peuplement depuis le log déjà écrit). ✓
|
||||
- snapshots.kind/schema_version/important_json → Task 1 + Task 3. ✓
|
||||
- executions.schema_version/important_json/error_kind/error_message → Task 1 + Task 4. ✓
|
||||
|
||||
Décision assumée (rétro-compat) : `snapshots.checked_at` conservé (non renommé en `created_at`) pour ne pas casser `refresh.ts`. Tables sans producteur en Phase 1 (`machine_hardware`, `machine_metrics_latest`, `important_messages`) créées vides, alimentées aux tâches 4/5/7 — conforme au principe « migration progressive ».
|
||||
|
||||
Pas de placeholder. Noms cohérents : `deriveAptState`/`upsertMachineState`/`recordEvent` définis Task 2 et utilisés Tasks 3-4 ; `reportId` défini Task 4 Step 4 et réutilisé Step 5.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Tâche 1.9 — Phase 2 (sécurité credentials) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development ou superpowers:executing-plans. Étapes en checkbox.
|
||||
|
||||
**Goal:** Isoler les secrets SSH dans une table dédiée `machine_credentials` (+ table `machine_host_keys`), de façon **non destructive** : nouvelle table, écriture dédiée, lecture prioritaire avec fallback sur `machines.enc_password`, et backfill des machines existantes.
|
||||
|
||||
**Architecture:** Ajout additif (Drizzle/SQLite, migration `0003`). `machines.enc_password`/`enc_sudo_password` sont CONSERVÉS (non droppés) comme fallback/legacy. Un service `credentials` écrit/lit `machine_credentials` ; `createMachine` y insère, `getCreds` lit `machine_credentials` puis retombe sur les colonnes `machines` si absent ; un backfill (idempotent) crée les lignes manquantes au démarrage. `machine_host_keys` est créée (schéma) pour la future vérification host key (pas de logique de vérif en Phase 2).
|
||||
|
||||
**Tech Stack:** Drizzle ORM, better-sqlite3, drizzle-kit, vitest.
|
||||
|
||||
---
|
||||
|
||||
## Invariants
|
||||
- **Non destructif** : ne pas dropper `machines.enc_password`/`enc_sudo_password` (NOT NULL conservé). Pas de perte des machines réelles existantes.
|
||||
- Secrets uniquement chiffrés (AES-256-GCM existant, `server/crypto/secrets.ts`). `machine_credentials` n'est JAMAIS exposée via l'API publique (la `MachineView` reste sans secret).
|
||||
- Rétro-compatibilité : une machine sans ligne `machine_credentials` reste utilisable (fallback). Le backfill comble le manque.
|
||||
- **Ne pas committer** (l'utilisateur gère les commits). Étapes « commit » remplacées par vérification.
|
||||
- Tree partagé avec du WIP concurrent : ne toucher QUE `server/db/schema.ts`, migrations, `server/services/credentials.ts` (+test), `server/services/machines.ts`, et le point de backfill (`server/db/migrate.ts` ou `server/index.ts`). Relire chaque fichier avant édition (drift possible).
|
||||
|
||||
## File Structure
|
||||
```
|
||||
server/db/schema.ts # MODIF : +machine_credentials, +machine_host_keys
|
||||
server/db/migrations/0003_*.sql # généré
|
||||
server/services/credentials.ts # NOUVEAU : writeCredentials/readCreds/backfill
|
||||
server/services/credentials.test.ts # NOUVEAU
|
||||
server/services/machines.ts # MODIF : createMachine écrit credentials ; getCreds lit credentials+fallback
|
||||
server/db/migrate.ts # MODIF : appeler backfill après migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Tables `machine_credentials` + `machine_host_keys`
|
||||
|
||||
**Files:** Modify `server/db/schema.ts` ; generate migration ; extend `server/db/schema.test.ts`.
|
||||
|
||||
- [ ] **Step 1 : Relire le schéma réel**
|
||||
|
||||
Run: `rtk read server/db/schema.ts` (capter l'état courant, préserver tout l'existant : machines/snapshots/executions/apiClients + les 7 tables Phase 1).
|
||||
|
||||
- [ ] **Step 2 : Ajouter les deux tables** (à la fin de `schema.ts`, avant ou après `apiClients`, sans rien supprimer)
|
||||
|
||||
```ts
|
||||
export const machineCredentials = sqliteTable("machine_credentials", {
|
||||
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
|
||||
authMethod: text("auth_method").notNull(), // password | ssh_key
|
||||
encPassword: text("enc_password"),
|
||||
encSudoPassword: text("enc_sudo_password"),
|
||||
encPrivateKey: text("enc_private_key"),
|
||||
encKeyPassphrase: text("enc_key_passphrase"),
|
||||
sudoMode: text("sudo_mode").notNull(), // same_as_ssh | separate | none
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
lastTestAt: text("last_test_at"),
|
||||
status: text("status"), // ok | error | unknown
|
||||
});
|
||||
|
||||
export const machineHostKeys = sqliteTable("machine_host_keys", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
|
||||
hostname: text("hostname").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
keyType: text("key_type"),
|
||||
fingerprintSha256: text("fingerprint_sha256").notNull(),
|
||||
publicKey: text("public_key"),
|
||||
status: text("status").notNull(), // approved | changed | rejected | unknown
|
||||
firstSeenAt: text("first_seen_at").notNull(),
|
||||
lastSeenAt: text("last_seen_at").notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Générer la migration**
|
||||
|
||||
Run: `rtk pnpm db:generate`
|
||||
Expected: `server/db/migrations/0003_*.sql` (CREATE TABLE machine_credentials + machine_host_keys uniquement). Vérifier qu'aucun DROP ni recréation de table existante n'apparaît (sinon corriger le schéma et régénérer).
|
||||
|
||||
- [ ] **Step 4 : Étendre `server/db/schema.test.ts`** — ajouter un test
|
||||
|
||||
```ts
|
||||
it("crée les tables de credentials Phase 2", () => {
|
||||
const sqlite = freshMigratedDb();
|
||||
const tables = tableNames(sqlite);
|
||||
expect(tables).toEqual(expect.arrayContaining(["machine_credentials", "machine_host_keys"]));
|
||||
// machines conserve ses colonnes secrets legacy (fallback)
|
||||
expect(columnNames(sqlite, "machines")).toContain("enc_password");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/db/schema.test.ts` → PASS. Puis `rtk pnpm check` → 0 erreur.
|
||||
|
||||
- [ ] **Step 6 : (pas de commit)**
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Service `credentials` (write / read / backfill) — TDD
|
||||
|
||||
**Files:** Create `server/services/credentials.ts`, `server/services/credentials.test.ts`.
|
||||
|
||||
- [ ] **Step 1 : Test (échec attendu)** — `server/services/credentials.test.ts`
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveCreds } from "./credentials.js";
|
||||
|
||||
describe("resolveCreds", () => {
|
||||
it("préfère la ligne machine_credentials", () => {
|
||||
const out = resolveCreds(
|
||||
{ encPassword: "M_PWD", encSudoPassword: null }, // machines (legacy)
|
||||
{ encPassword: "C_PWD", encSudoPassword: "C_SUDO" }, // machine_credentials
|
||||
);
|
||||
expect(out).toEqual({ encPassword: "C_PWD", encSudoPassword: "C_SUDO" });
|
||||
});
|
||||
it("retombe sur machines si pas de credentials", () => {
|
||||
const out = resolveCreds({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" }, null);
|
||||
expect(out).toEqual({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2 :** Run `rtk pnpm vitest run server/services/credentials.test.ts` → FAIL (module manquant).
|
||||
|
||||
- [ ] **Step 3 : Implémenter `server/services/credentials.ts`**
|
||||
|
||||
```ts
|
||||
// server/services/credentials.ts
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
|
||||
interface EncPair { encPassword: string | null; encSudoPassword: string | null; }
|
||||
|
||||
/** Résout la source des secrets : machine_credentials prioritaire, sinon legacy machines (fonction pure). */
|
||||
export function resolveCreds(legacy: EncPair, creds: EncPair | null): EncPair {
|
||||
if (creds && creds.encPassword) return { encPassword: creds.encPassword, encSudoPassword: creds.encSudoPassword };
|
||||
return { encPassword: legacy.encPassword, encSudoPassword: legacy.encSudoPassword };
|
||||
}
|
||||
|
||||
/** Écrit (insert/replace) la ligne machine_credentials pour une machine (secrets déjà chiffrés). */
|
||||
export function writeCredentials(input: {
|
||||
machineId: string;
|
||||
encPassword: string | null;
|
||||
encSudoPassword: string | null;
|
||||
}): void {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(schema.machineCredentials)
|
||||
.values({
|
||||
machineId: input.machineId,
|
||||
authMethod: "password",
|
||||
encPassword: input.encPassword,
|
||||
encSudoPassword: input.encSudoPassword,
|
||||
sudoMode: input.encSudoPassword ? "separate" : "same_as_ssh",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: "unknown",
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.machineCredentials.machineId,
|
||||
set: { encPassword: input.encPassword, encSudoPassword: input.encSudoPassword, updatedAt: now },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Lit la ligne machine_credentials (ou null). */
|
||||
export function readCredentials(machineId: string): EncPair | null {
|
||||
const row = db.select().from(schema.machineCredentials)
|
||||
.where(eq(schema.machineCredentials.machineId, machineId)).get();
|
||||
return row ? { encPassword: row.encPassword, encSudoPassword: row.encSudoPassword } : null;
|
||||
}
|
||||
|
||||
/** Backfill idempotent : crée une ligne machine_credentials pour chaque machine qui n'en a pas. */
|
||||
export function backfillCredentials(): number {
|
||||
const machines = db.select().from(schema.machines).all();
|
||||
let created = 0;
|
||||
for (const m of machines) {
|
||||
if (readCredentials(m.id)) continue;
|
||||
writeCredentials({ machineId: m.id, encPassword: m.encPassword, encSudoPassword: m.encSudoPassword });
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 :** Run `rtk pnpm vitest run server/services/credentials.test.ts` → PASS (2). (Le test n'appelle que `resolveCreds`, pur ; pas de DB.)
|
||||
|
||||
- [ ] **Step 5 : (pas de commit)**
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Brancher dans `machines.ts` + backfill au démarrage
|
||||
|
||||
**Files:** Modify `server/services/machines.ts`, `server/db/migrate.ts`.
|
||||
|
||||
- [ ] **Step 1 : Relire `server/services/machines.ts`** (état réel : `getCreds`, `createMachine`).
|
||||
|
||||
- [ ] **Step 2 : `createMachine` écrit aussi machine_credentials**
|
||||
|
||||
Importer en tête : `import { writeCredentials, readCredentials, resolveCreds } from "./credentials.js";`
|
||||
Après l'`insert` de la ligne `machines` (avant `return toView(row)`), ajouter :
|
||||
```ts
|
||||
writeCredentials({ machineId: id, encPassword: row.encPassword, encSudoPassword: row.encSudoPassword });
|
||||
```
|
||||
(On conserve aussi l'écriture dans `machines.enc_password` — non destructif.)
|
||||
|
||||
- [ ] **Step 3 : `getCreds` lit machine_credentials en priorité**
|
||||
|
||||
Remplacer le corps de `getCreds` par :
|
||||
```ts
|
||||
export function getCreds(m: MachineRow): SshCreds {
|
||||
const key = env.requireMasterKey();
|
||||
const { encPassword, encSudoPassword } = resolveCreds(
|
||||
{ encPassword: m.encPassword, encSudoPassword: m.encSudoPassword },
|
||||
readCredentials(m.id),
|
||||
);
|
||||
if (!encPassword) throw new Error("Aucun secret pour cette machine");
|
||||
return {
|
||||
hostname: m.hostname,
|
||||
port: m.port,
|
||||
username: m.username,
|
||||
password: decryptSecret(encPassword, key),
|
||||
sudoPassword: encSudoPassword ? decryptSecret(encSudoPassword, key) : null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Backfill au démarrage** — dans `server/db/migrate.ts`, après `runMigrations()`, exposer et appeler le backfill. Modifier `runMigrations` pour enchaîner :
|
||||
```ts
|
||||
// server/db/migrate.ts
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { db } from "./client.js";
|
||||
import { backfillCredentials } from "../services/credentials.js";
|
||||
|
||||
export function runMigrations(): void {
|
||||
migrate(db, { migrationsFolder: "./server/db/migrations" });
|
||||
const n = backfillCredentials();
|
||||
if (n > 0) console.log(`[migrate] backfill credentials: ${n} machine(s)`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5 :** Run `rtk pnpm check && rtk pnpm test` → 0 erreur TS ; tests verts (48 attendus : +2 credentials). Si un test hors périmètre (WIP concurrent) casse, le signaler sans corriger.
|
||||
|
||||
- [ ] **Step 6 : (pas de commit)**
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Vérification finale Phase 2
|
||||
|
||||
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert + `dist` produit.
|
||||
|
||||
- [ ] **Step 2 : Boot + backfill + tables**
|
||||
```bash
|
||||
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/p2.db SU_REPORTS_DIR=./data/p2-reports
|
||||
node dist/index.js > ./data/p2.log 2>&1 &
|
||||
sleep 3
|
||||
curl -s localhost:8787/health
|
||||
node -e "const D=require('better-sqlite3');const db=new D('./data/p2.db');console.log(db.prepare(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'machine_%'\").all().map(r=>r.name).join(', '));"
|
||||
kill %1 2>/dev/null
|
||||
rm -rf ./data/p2.db* ./data/p2-reports ./data/p2.log
|
||||
```
|
||||
Expected: `{"ok":true}` ; tables incluent `machine_credentials`, `machine_host_keys`. (Backfill = 0 sur DB neuve, normal.)
|
||||
|
||||
- [ ] **Step 3 :** Reporter à l'utilisateur (tables ajoutées, dual-read/backfill, non-régression). **Ne pas committer.**
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (couverture tache1.9 §14 Phase 2)
|
||||
- créer `machine_credentials` → Task 1. ✓
|
||||
- migrer `enc_password`/`enc_sudo_password` → approche non destructive : dual-write + backfill + lecture prioritaire (Tasks 2-3). Les colonnes legacy restent comme fallback (drop = phase ultérieure de nettoyage). ✓
|
||||
- créer `machine_host_keys` → Task 1 (schéma ; vérification host key = logique ultérieure). ✓
|
||||
- audit événements secrets → léger : non inclus en Phase 2 (le `recordEvent` Phase 1 existe ; l'audit systématique des déchiffrements relève de tâche 7 sécurité). Noté comme suite.
|
||||
|
||||
Décision assumée : non destructif (pas de DROP des colonnes secrets de `machines`) pour protéger les machines réelles existantes. Noms cohérents : `resolveCreds`/`writeCredentials`/`readCredentials`/`backfillCredentials` définis Task 2, utilisés Task 3.
|
||||
@@ -0,0 +1,408 @@
|
||||
# Tâche 2 — SJ-0 (socle : types + réduction + résolution de profil) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
|
||||
|
||||
**Goal:** Poser le socle de la tâche 2, **purement additif** : étendre `shared/types.ts` (unions élargies + blocs optionnels, rétro-compatibles), enrichir le réducteur de lignes (préfixes Docker), et ajouter `resolveTemplate(action, osFamily)` avec fallback `base`. Aucun changement de wiring (refresh/execute inchangés).
|
||||
|
||||
**Architecture:** Extensions additives. Référence design : `docs/design/tache2/40-contrats-json.md` (types), `60-profils-os-machine.md` (résolution), `99-couverture-gate.md`. Tous les ajouts sont optionnels/élargis ⇒ un `UpdateSnapshot`/`ExecutionResult` du jalon 1 reste strictement valide (vérifié par `tsc`).
|
||||
|
||||
**Tech Stack:** TypeScript, vitest.
|
||||
|
||||
---
|
||||
|
||||
## Invariants
|
||||
- **Rétro-compat stricte** : ne rien retirer/renommer. Préserver `MachineStatus`, `MachineView`, `ServerCapabilities` (WIP) et tout autre contenu actuel de `shared/types.ts`.
|
||||
- **Aucun changement de comportement** : on n'altère PAS `refresh.ts`/`execute.ts` en SJ-0 (la bascule du refresh sur les nouveaux templates = SJ-1).
|
||||
- Réducteur : **garder `reduceAptLines`** (imports existants dans refresh/execute) ; ajouter les préfixes Docker et un alias `reduceLines`. **Ne PAS renommer le fichier** `aptReduce.ts` (éviter de toucher les imports de refresh/execute — churn/concurrence).
|
||||
- Tree partagé / WIP concurrent : ne toucher QUE `shared/types.ts`, `server/templates/aptReduce.ts` (+test), `server/templates/render.ts` (+ test resolveTemplate), et les fichiers de test. **Ne pas committer.**
|
||||
|
||||
## File Structure
|
||||
```
|
||||
shared/types.ts # MODIF : unions élargies + interfaces + champs optionnels
|
||||
shared/types.test.ts # NOUVEAU : verrouille la rétro-compat (compile + runtime léger)
|
||||
server/templates/aptReduce.ts # MODIF : préfixes Docker + alias reduceLines
|
||||
server/templates/aptReduce.test.ts # MODIF : +cas Docker
|
||||
server/templates/render.ts # MODIF : +resolveTemplate
|
||||
server/templates/resolveTemplate.test.ts # NOUVEAU
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Étendre `shared/types.ts`
|
||||
|
||||
**Files:** Modify `shared/types.ts` ; Create `shared/types.test.ts`.
|
||||
|
||||
- [ ] **Step 1 : Relire le fichier réel** (`rtk read shared/types.ts`) pour repérer le contenu à préserver (`MachineStatus`, `MachineView`, `ServerCapabilities`, etc.).
|
||||
|
||||
- [ ] **Step 2 : Appliquer les extensions** (élargir les unions existantes, remplacer `AptPackage`/`UpdateSnapshot`/`ExecutionResult` par les versions étendues, AJOUTER les nouvelles interfaces). Ne pas supprimer l'existant. Contenu cible (depuis `docs/design/tache2/40-contrats-json.md`) :
|
||||
|
||||
```ts
|
||||
export type OsFamily = "debian" | "ubuntu" | "proxmox" | "raspbian" | "unknown";
|
||||
export type MachineKind =
|
||||
| "physical" | "vm" | "proxmox_host" | "lxc"
|
||||
| "raspberry_pi" | "workstation" | "unknown";
|
||||
export type AptProxyMode = "direct" | "runtime" | "persistent";
|
||||
export type ActionType =
|
||||
| "apt_full_upgrade" | "reboot"
|
||||
| "apt_update_analyze" | "apt_upgrade" | "apt_dist_upgrade"
|
||||
| "apt_autoremove" | "apt_clean" | "reboot_verified"
|
||||
| "docker_scan" | "docker_inspect_current" | "docker_pull_check"
|
||||
| "docker_compose_apply" | "docker_prune_images" | "docker_compose_down"
|
||||
| "machine_probe" | "post_install";
|
||||
export type SnapshotStatus = "ok" | "updates_available" | "warning" | "error";
|
||||
// ExecutionStatus, MachineStatus : INCHANGÉS (préserver l'existant)
|
||||
|
||||
export interface AptPackage {
|
||||
name: string;
|
||||
currentVersion: string | null;
|
||||
targetVersion: string;
|
||||
origin: string | null;
|
||||
arch?: string;
|
||||
operation?: "upgrade" | "install" | "remove" | "hold";
|
||||
severityHint?: "normal" | "security";
|
||||
}
|
||||
|
||||
export interface AptSnapshotDetail {
|
||||
enabled: boolean;
|
||||
count: number;
|
||||
rebootRequired: boolean;
|
||||
packages: AptPackage[];
|
||||
status?: SnapshotStatus;
|
||||
upgradeCount?: number;
|
||||
distUpgradeCount?: number;
|
||||
installed?: AptPackage[];
|
||||
removed?: AptPackage[];
|
||||
held?: string[];
|
||||
rebootPkgs?: string[];
|
||||
}
|
||||
|
||||
export interface DockerSnapshotService {
|
||||
serviceName: string;
|
||||
image: string;
|
||||
currentImageId?: string | null;
|
||||
currentDigest?: string | null;
|
||||
candidateImageId?: string | null;
|
||||
candidateDigest?: string | null;
|
||||
currentVersion?: string | null;
|
||||
candidateVersion?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
status?: "up_to_date" | "updates_available" | "warning" | "error";
|
||||
}
|
||||
export interface DockerSnapshotStack {
|
||||
name: string;
|
||||
workingDir: string;
|
||||
composeFiles: string[];
|
||||
projectName?: string | null;
|
||||
status: "candidate" | "enabled" | "ignored" | "error";
|
||||
detectedBy?: "root_scan" | "label" | "manual";
|
||||
services: DockerSnapshotService[];
|
||||
}
|
||||
export interface DockerSnapshot {
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
count: number;
|
||||
declaredRoots?: string[];
|
||||
stacks: DockerSnapshotStack[];
|
||||
status?: SnapshotStatus;
|
||||
}
|
||||
|
||||
export interface SnapshotError {
|
||||
source: "apt" | "docker" | "post_install" | "ssh" | "system";
|
||||
kind: string;
|
||||
severity: "info" | "warning" | "error";
|
||||
message: string;
|
||||
remediation?: string;
|
||||
importantLines?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateSnapshot {
|
||||
machineId: string;
|
||||
hostname: string;
|
||||
os: { family: OsFamily; version: string };
|
||||
checkedAt: string;
|
||||
status: MachineStatus;
|
||||
apt: AptSnapshotDetail;
|
||||
schemaVersion?: number;
|
||||
kind?: "apt_update_analyze" | "docker_scan" | "reboot_check" | "combined";
|
||||
machineKind?: MachineKind;
|
||||
docker?: DockerSnapshot;
|
||||
errors?: SnapshotError[];
|
||||
rawHints?: { logImportantLines: string[] };
|
||||
}
|
||||
|
||||
export interface AptChange {
|
||||
name: string;
|
||||
arch?: string;
|
||||
fromVersion: string | null;
|
||||
toVersion: string | null;
|
||||
operation: "upgraded" | "installed" | "removed" | "unchanged";
|
||||
origin?: string | null;
|
||||
}
|
||||
export interface AptExecutionResult {
|
||||
planned: AptPackage[];
|
||||
applied: AptChange[];
|
||||
installed: AptChange[];
|
||||
removed: AptChange[];
|
||||
held: string[];
|
||||
errors?: SnapshotError[];
|
||||
rebootRequiredAfterRun: boolean;
|
||||
}
|
||||
export interface DockerImageChange {
|
||||
stack: string;
|
||||
serviceName?: string;
|
||||
imageRef?: string;
|
||||
fromImageId?: string | null;
|
||||
toImageId?: string | null;
|
||||
fromDigest?: string | null;
|
||||
toDigest?: string | null;
|
||||
operation: "pulled" | "recreated" | "pruned";
|
||||
}
|
||||
export interface DockerExecutionResult {
|
||||
pull?: { changes: DockerImageChange[]; errors?: SnapshotError[] };
|
||||
up?: { recreated: string[]; running: string[]; exited: string[]; errors?: SnapshotError[] };
|
||||
prune?: { imagesDeleted: string[]; bytesReclaimed: number; errors?: SnapshotError[] };
|
||||
errors?: SnapshotError[];
|
||||
}
|
||||
export interface RebootResult {
|
||||
beforeBootId: string | null;
|
||||
afterBootId: string | null;
|
||||
requestedAt: string;
|
||||
sshWentDownAt: string | null;
|
||||
sshCameBackAt: string | null;
|
||||
waitedSeconds: number;
|
||||
status: "ok" | "reboot_command_failed" | "ssh_never_went_down"
|
||||
| "machine_did_not_return" | "boot_id_unchanged" | "timeout";
|
||||
lastRebootDurationSeconds?: number;
|
||||
nextRecommendedWaitSeconds?: number;
|
||||
errors?: SnapshotError[];
|
||||
}
|
||||
export interface PostInstallResult {
|
||||
profilesRun: string[];
|
||||
variablesUsed: Record<string, string | number | boolean>;
|
||||
filesModified: string[];
|
||||
packagesInstalled: string[];
|
||||
servicesEnabled: string[];
|
||||
rebootsRequested: boolean;
|
||||
networkChange?: { oldEndpoint: string | null; newEndpoint: string | null; reconnectHost: string | null };
|
||||
errors?: SnapshotError[];
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
executionId: string;
|
||||
machineId: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
mode: "manual" | "scheduled" | "hermes_requested";
|
||||
action: ActionType;
|
||||
status: ExecutionStatus;
|
||||
rebootRequiredAfterRun: boolean;
|
||||
importantLogLines: string[];
|
||||
rawLogRef: string;
|
||||
reportRef: string;
|
||||
schemaVersion?: number;
|
||||
apt?: AptExecutionResult;
|
||||
docker?: DockerExecutionResult;
|
||||
reboot?: RebootResult;
|
||||
postInstall?: PostInstallResult;
|
||||
errors?: SnapshotError[];
|
||||
}
|
||||
```
|
||||
|
||||
> Préserver `MachineStatus`, `MachineView`, `ServerCapabilities` et tout autre contenu présent. Le bloc `apt` de `UpdateSnapshot` reste **requis** (forme jalon 1) ; `mode` de `ExecutionResult` était le littéral `"manual"` → l'union l'inclut.
|
||||
|
||||
- [ ] **Step 3 : Test de rétro-compat `shared/types.test.ts`**
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { UpdateSnapshot, ExecutionResult } from "./types.js";
|
||||
|
||||
describe("rétro-compatibilité des contrats", () => {
|
||||
it("un snapshot jalon 1 (sans blocs optionnels) reste valide", () => {
|
||||
const snap: UpdateSnapshot = {
|
||||
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
|
||||
checkedAt: "2026-06-05T10:00:00Z", status: "ok",
|
||||
apt: { enabled: true, count: 0, rebootRequired: false, packages: [] },
|
||||
};
|
||||
expect(snap.apt.count).toBe(0);
|
||||
});
|
||||
|
||||
it("une exécution jalon 1 (mode manual, sans blocs) reste valide", () => {
|
||||
const exec: ExecutionResult = {
|
||||
executionId: "e1", machineId: "m1", startedAt: "a", finishedAt: "b",
|
||||
mode: "manual", action: "apt_full_upgrade", status: "ok",
|
||||
rebootRequiredAfterRun: false, importantLogLines: [], rawLogRef: "r", reportRef: "rr",
|
||||
};
|
||||
expect(exec.action).toBe("apt_full_upgrade");
|
||||
});
|
||||
|
||||
it("accepte les nouveaux blocs optionnels", () => {
|
||||
const snap: UpdateSnapshot = {
|
||||
machineId: "m1", hostname: "h", os: { family: "proxmox", version: "8" },
|
||||
checkedAt: "t", status: "updates_available",
|
||||
apt: { enabled: true, count: 1, rebootRequired: false, packages: [], status: "updates_available" },
|
||||
schemaVersion: 1, kind: "apt_update_analyze", machineKind: "proxmox_host",
|
||||
docker: { enabled: false, installed: false, count: 0, stacks: [] },
|
||||
errors: [],
|
||||
};
|
||||
expect(snap.docker?.installed).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4 :** Run `rtk pnpm vitest run shared/types.test.ts` → PASS (3). Puis `rtk pnpm check` → **0 erreur** (c'est le vrai test de rétro-compat : si un consommateur existant casse à cause d'un retrait/renommage, tsc le révèle). Si `check` signale une erreur dans un fichier consommateur (`refresh.ts`/`execute.ts`/`machines.ts`/WIP) causée par TON changement de types, corrige le type (rends additif) — ne casse pas les consommateurs.
|
||||
|
||||
- [ ] **Step 5 : (pas de commit)**
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Réducteur enrichi (préfixes Docker)
|
||||
|
||||
**Files:** Modify `server/templates/aptReduce.ts`, `server/templates/aptReduce.test.ts`.
|
||||
|
||||
- [ ] **Step 1 : Relire `server/templates/aptReduce.ts`** (état réel).
|
||||
|
||||
- [ ] **Step 2 : Ajouter un cas Docker au test `aptReduce.test.ts`**
|
||||
|
||||
```ts
|
||||
it("garde aussi les lignes Docker utiles", () => {
|
||||
const raw = [
|
||||
"Pulling jellyfin ...",
|
||||
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
|
||||
"Recreating jellyfin ...",
|
||||
"Started jellyfin",
|
||||
"blabla inutile",
|
||||
"Total reclaimed space: 1.2GB",
|
||||
].join("\n");
|
||||
expect(reduceLines(raw)).toEqual([
|
||||
"Pulling jellyfin ...",
|
||||
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
|
||||
"Recreating jellyfin ...",
|
||||
"Started jellyfin",
|
||||
"Total reclaimed space: 1.2GB",
|
||||
]);
|
||||
});
|
||||
```
|
||||
Ajouter `reduceLines` à l'import existant : `import { reduceAptLines, reduceLines } from "./aptReduce.js";`
|
||||
|
||||
- [ ] **Step 3 : Lancer (échec attendu)** — `rtk pnpm vitest run server/templates/aptReduce.test.ts` → FAIL (`reduceLines` introuvable / lignes Docker non gardées).
|
||||
|
||||
- [ ] **Step 4 : Étendre `server/templates/aptReduce.ts`**
|
||||
|
||||
```ts
|
||||
// server/templates/aptReduce.ts
|
||||
const PREFIXES = [
|
||||
// APT / dpkg (jalon 1)
|
||||
"Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:",
|
||||
// Docker (SJ-0)
|
||||
"Pulling", "Digest", "Status", "Downloaded newer image", "Recreating", "Started", "Error",
|
||||
];
|
||||
const CONTAINS = [
|
||||
"reboot-required", "REBOOT_REQUIRED",
|
||||
"deleted", "Total reclaimed space",
|
||||
];
|
||||
|
||||
/** Garde uniquement les lignes informatives (APT + Docker) d'une sortie brute. */
|
||||
export function reduceLines(raw: string): string[] {
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())
|
||||
.filter((l) => PREFIXES.some((p) => l.startsWith(p)) || CONTAINS.some((c) => l.includes(c)));
|
||||
}
|
||||
|
||||
/** Alias rétro-compatible (jalon 1) : même comportement, conserve les imports existants. */
|
||||
export const reduceAptLines = reduceLines;
|
||||
```
|
||||
|
||||
> Garder l'export `reduceAptLines` (utilisé par `refresh.ts`/`execute.ts`). `reduceLines` est le nouveau nom canonique.
|
||||
|
||||
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/templates/aptReduce.test.ts` → PASS (cas APT existants + nouveau cas Docker). `rtk pnpm check` → 0 erreur.
|
||||
|
||||
- [ ] **Step 6 : (pas de commit)**
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : `resolveTemplate(action, osFamily)`
|
||||
|
||||
**Files:** Modify `server/templates/render.ts` ; Create `server/templates/resolveTemplate.test.ts`.
|
||||
|
||||
- [ ] **Step 1 : Relire `server/templates/render.ts`** (état réel : `TEMPLATES_ROOT`, `renderTemplate`, `TemplateVars`).
|
||||
|
||||
- [ ] **Step 2 : Test `server/templates/resolveTemplate.test.ts`**
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveTemplate } from "./render.js";
|
||||
|
||||
describe("resolveTemplate", () => {
|
||||
it("retombe sur apt/ quand aucun dossier OS spécifique n'existe (fonction exists fournie)", () => {
|
||||
const noneExist = () => false;
|
||||
expect(resolveTemplate("full-upgrade", "proxmox", noneExist)).toBe("apt/full-upgrade.sh.tpl");
|
||||
expect(resolveTemplate("update-analyze", "debian", noneExist)).toBe("apt/update-analyze.sh.tpl");
|
||||
});
|
||||
|
||||
it("choisit le template OS spécifique quand il existe", () => {
|
||||
const proxmoxExists = (rel: string) => rel === "proxmox/full-upgrade.sh.tpl";
|
||||
expect(resolveTemplate("full-upgrade", "proxmox", proxmoxExists)).toBe("proxmox/full-upgrade.sh.tpl");
|
||||
});
|
||||
|
||||
it("unknown retombe toujours sur apt/", () => {
|
||||
const all = () => true;
|
||||
expect(resolveTemplate("clean", "unknown", all)).toBe("apt/clean.sh.tpl");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer (échec)** — `rtk pnpm vitest run server/templates/resolveTemplate.test.ts` → FAIL.
|
||||
|
||||
- [ ] **Step 4 : Ajouter `resolveTemplate` à `server/templates/render.ts`** (sans toucher `renderTemplate`/`TemplateVars` existants ; ajouter l'import `existsSync`) :
|
||||
|
||||
```ts
|
||||
import { existsSync } from "node:fs";
|
||||
// ... (TEMPLATES_ROOT, renderTemplate existants inchangés) ...
|
||||
|
||||
/** Existence par défaut d'un template relatif à templates/. */
|
||||
function defaultExists(rel: string): boolean {
|
||||
return existsSync(resolve(TEMPLATES_ROOT, rel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le chemin de template le plus spécifique pour (action, OS) :
|
||||
* `<osFamily>/<action>.sh.tpl` s'il existe, sinon fallback base `apt/<action>.sh.tpl`.
|
||||
* `exists` est injectable pour les tests.
|
||||
*/
|
||||
export function resolveTemplate(
|
||||
action: string,
|
||||
osFamily: string,
|
||||
exists: (rel: string) => boolean = defaultExists,
|
||||
): string {
|
||||
const specific = `${osFamily}/${action}.sh.tpl`;
|
||||
if (osFamily !== "unknown" && osFamily !== "apt" && exists(specific)) return specific;
|
||||
return `apt/${action}.sh.tpl`;
|
||||
}
|
||||
```
|
||||
|
||||
> Note : `renderTemplate` accepte déjà un `relPath` (ex. `apt/full-upgrade.sh.tpl`), donc `renderTemplate(resolveTemplate(action, osFamily), vars)` fonctionnera en SJ-1 sans modifier `renderTemplate`.
|
||||
|
||||
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/templates/resolveTemplate.test.ts` → PASS (3). `rtk pnpm check` → 0 erreur.
|
||||
|
||||
- [ ] **Step 6 : (pas de commit)**
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Vérification finale SJ-0
|
||||
|
||||
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build`
|
||||
Expected: 0 erreur TS ; tous tests verts (49 Phase 2 + 3 types + 1 Docker reduce + 3 resolveTemplate ≈ 56) ; build OK.
|
||||
|
||||
- [ ] **Step 2 :** Reporter : types étendus rétro-compatibles (tsc vert = preuve), réducteur Docker, `resolveTemplate` prêt pour SJ-1. **Ne pas committer.**
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (couverture SJ-0)
|
||||
- Types étendus (unions + blocs optionnels) → Task 1, rétro-compat verrouillée par `tsc` + test. ✓
|
||||
- Réducteur + préfixes Docker → Task 2 (`reduceLines` + alias `reduceAptLines` conservé). ✓
|
||||
- `resolveTemplate(action, osFamily)` + fallback base → Task 3. ✓
|
||||
- `schemaVersion` → présent dans `UpdateSnapshot`/`ExecutionResult` (optionnel). ✓
|
||||
- Aucun wiring modifié (refresh/execute intacts) ⇒ non-régression jalon 1. ✓
|
||||
|
||||
Décisions assumées : fichier `aptReduce.ts` NON renommé (alias `reduceLines` ajouté) pour éviter de toucher les imports de refresh/execute (churn/concurrence) — le nom canonique `reduceLines` est exporté ; renommage physique reporté à un nettoyage ultérieur. `resolveTemplate` avec `exists` injectable pour testabilité des deux branches.
|
||||
@@ -0,0 +1,349 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,325 @@
|
||||
# Tâche 2 — SJ-2 (APT apply + diff dpkg réel) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
|
||||
|
||||
**Goal:** Enrichir `apt/full-upgrade.sh.tpl` du snapshot dpkg avant/après, ajouter `apt/upgrade.sh.tpl`, `apt/autoremove.sh.tpl`, `apt/clean.sh.tpl`, calculer le **diff dpkg réel** (`AptExecutionResult` : applied/installed/removed), brancher les actions `apt_upgrade`/`apt_autoremove`/`apt_clean` (+ `apt_full_upgrade` enrichi) dans `runAction`, et ajouter un **timeout d'inactivité** optionnel à la couche SSH.
|
||||
|
||||
**Architecture:** Additif. Référence : `docs/design/tache2/10-templates-apt.md §4.2-4.4`, `40-contrats-json.md §4` (`AptExecutionResult`/`AptChange`), `50-erreurs.md` (`human_interaction_required`). Le diff dpkg est calculé en TS (`buildAptExecutionResult`, pure, TDD). `runScriptSudo` reçoit une option `inactivityTimeoutMs` (défaut 0 = désactivé ⇒ comportement jalon 1 inchangé) ; `runAction` la passe (600000) pour les actions APT. Les confirmations UI des suppressions relèvent de la tâche 3 ; SJ-2 expose `removed[]` dans le résultat.
|
||||
|
||||
**Tech Stack:** TypeScript, Mustache, ssh2, vitest.
|
||||
|
||||
---
|
||||
|
||||
## Invariants
|
||||
- `apt_full_upgrade` et `reboot` (jalon 1) restent fonctionnels ; on **enrichit** sans casser le parsing exit/reboot existant de `execute.ts`.
|
||||
- `runScriptSudo` : nouveau paramètre **optionnel** `inactivityTimeoutMs` (défaut 0 = pas de timeout) ⇒ `refreshMachine` et tout appelant existant **inchangés** de comportement.
|
||||
- `ExecutionResult.apt` est optionnel (SJ-0) ⇒ une exécution sans diff reste valide.
|
||||
- Tree partagé / WIP concurrent : ne toucher QUE `server/services/aptParse.ts` (+test/fixtures), `templates/apt/{full-upgrade,upgrade,autoremove,clean}.sh.tpl`, `server/ssh/client.ts`, `server/services/execute.ts`. **Ne pas committer.**
|
||||
|
||||
## File Structure
|
||||
```
|
||||
server/services/aptParse.ts # MODIF : +parseDpkgList/diffDpkg/buildAptExecutionResult
|
||||
server/services/aptParse.test.ts # MODIF : +tests diff dpkg
|
||||
templates/apt/full-upgrade.sh.tpl # MODIF : +DPKG_BEFORE/AFTER
|
||||
templates/apt/upgrade.sh.tpl # NOUVEAU
|
||||
templates/apt/autoremove.sh.tpl # NOUVEAU
|
||||
templates/apt/clean.sh.tpl # NOUVEAU
|
||||
server/ssh/client.ts # MODIF : +inactivityTimeoutMs (additif)
|
||||
server/services/execute.ts # MODIF : actions APT + buildAptExecutionResult + timeout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Diff dpkg (TDD)
|
||||
|
||||
**Files:** Modify `server/services/aptParse.ts`, `server/services/aptParse.test.ts`.
|
||||
|
||||
- [ ] **Step 1 : Test (échec attendu)** — ajouter à `aptParse.test.ts`
|
||||
|
||||
```ts
|
||||
import { parseDpkgList, buildAptExecutionResult } from "./aptParse.js";
|
||||
|
||||
const BEFORE = "libc6\t2.31-13\tamd64\noldpkg\t3.2-1\tamd64\nstable\t1.0\tamd64";
|
||||
const AFTER = "libc6\t2.31-14\tamd64\nnewpkg\t1.0.0\tall\nstable\t1.0\tamd64";
|
||||
|
||||
describe("parseDpkgList", () => {
|
||||
it("indexe par package:arch", () => {
|
||||
const m = parseDpkgList("libc6\t2.31-13\tamd64");
|
||||
expect(m["libc6:amd64"]).toEqual({ version: "2.31-13", arch: "amd64" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAptExecutionResult", () => {
|
||||
it("calcule le diff réel before/after", () => {
|
||||
const r = buildAptExecutionResult(BEFORE, AFTER, "REBOOT_REQUIRED=1");
|
||||
expect(r.applied.find((c) => c.name === "libc6")).toMatchObject({ operation: "upgraded", fromVersion: "2.31-13", toVersion: "2.31-14" });
|
||||
expect(r.installed.map((c) => c.name)).toEqual(["newpkg"]);
|
||||
expect(r.removed.map((c) => c.name)).toEqual(["oldpkg"]);
|
||||
expect(r.applied.some((c) => c.name === "stable")).toBe(false); // unchanged exclu
|
||||
expect(r.rebootRequiredAfterRun).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer (échec)** — `rtk pnpm vitest run server/services/aptParse.test.ts` → FAIL.
|
||||
|
||||
- [ ] **Step 3 : Étendre `server/services/aptParse.ts`**
|
||||
|
||||
```ts
|
||||
import type { AptChange, AptExecutionResult } from "@shared/types.js";
|
||||
|
||||
export function parseDpkgList(raw: string): Record<string, { version: string; arch: string }> {
|
||||
const out: Record<string, { version: string; arch: string }> = {};
|
||||
for (const line of raw.split("\n")) {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length < 3) continue;
|
||||
const [name, version, arch] = [parts[0]!.trim(), parts[1]!.trim(), parts[2]!.trim()];
|
||||
if (!name) continue;
|
||||
out[`${name}:${arch}`] = { version, arch };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Diff dpkg réel before/after → AptExecutionResult (planned/held vides : portés par le snapshot). */
|
||||
export function buildAptExecutionResult(beforeRaw: string, afterRaw: string, rebootRaw: string): AptExecutionResult {
|
||||
const before = parseDpkgList(beforeRaw);
|
||||
const after = parseDpkgList(afterRaw);
|
||||
const applied: AptChange[] = [];
|
||||
const installed: AptChange[] = [];
|
||||
const removed: AptChange[] = [];
|
||||
|
||||
for (const key of Object.keys(after)) {
|
||||
const [name] = key.split(":");
|
||||
const a = after[key]!;
|
||||
const b = before[key];
|
||||
if (!b) {
|
||||
const change: AptChange = { name: name!, arch: a.arch, fromVersion: null, toVersion: a.version, operation: "installed" };
|
||||
installed.push(change); applied.push(change);
|
||||
} else if (b.version !== a.version) {
|
||||
applied.push({ name: name!, arch: a.arch, fromVersion: b.version, toVersion: a.version, operation: "upgraded" });
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(before)) {
|
||||
if (!after[key]) {
|
||||
const [name] = key.split(":");
|
||||
const b = before[key]!;
|
||||
const change: AptChange = { name: name!, arch: b.arch, fromVersion: b.version, toVersion: null, operation: "removed" };
|
||||
removed.push(change); applied.push(change);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
planned: [],
|
||||
applied,
|
||||
installed,
|
||||
removed,
|
||||
held: [],
|
||||
rebootRequiredAfterRun: /REBOOT_REQUIRED=1/.test(rebootRaw),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer (succès)** — `rtk pnpm vitest run server/services/aptParse.test.ts` → PASS. `rtk pnpm check` → 0 erreur.
|
||||
|
||||
- [ ] **Step 5 : (pas de commit)**
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Templates APT (full-upgrade enrichi + upgrade/autoremove/clean)
|
||||
|
||||
**Files:** Modify `templates/apt/full-upgrade.sh.tpl` ; Create `upgrade.sh.tpl`, `autoremove.sh.tpl`, `clean.sh.tpl`.
|
||||
|
||||
- [ ] **Step 1 : Remplacer `templates/apt/full-upgrade.sh.tpl`** (ajoute DPKG_BEFORE/AFTER ; conserve REBOOT + EXIT que `execute.ts` parse déjà)
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
|
||||
{{/aptProxy}}
|
||||
echo "===SU:DPKG_BEFORE==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:APT_FULLUPGRADE==="
|
||||
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold dist-upgrade 2>&1
|
||||
CODE=$?
|
||||
echo "===SU:DPKG_AFTER==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:REBOOT==="
|
||||
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Créer `templates/apt/upgrade.sh.tpl`**
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
|
||||
{{/aptProxy}}
|
||||
echo "===SU:DPKG_BEFORE==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:APT_UPGRADE==="
|
||||
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade 2>&1
|
||||
CODE=$?
|
||||
echo "===SU:DPKG_AFTER==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:REBOOT==="
|
||||
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Créer `templates/apt/autoremove.sh.tpl`**
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "===SU:APT_SIM_AUTOREMOVE==="
|
||||
apt-get -s -y autoremove 2>&1
|
||||
echo "===SU:DPKG_BEFORE==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:APT_AUTOREMOVE==="
|
||||
apt-get -y autoremove 2>&1
|
||||
CODE=$?
|
||||
echo "===SU:DPKG_AFTER==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:REBOOT==="
|
||||
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Créer `templates/apt/clean.sh.tpl`**
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
echo "===SU:APT_CLEAN==="
|
||||
BEFORE=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
|
||||
apt-get clean 2>&1
|
||||
AFTER=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
|
||||
echo "FREED_BYTES=$((BEFORE - AFTER))"
|
||||
echo "===SU:EXIT=0==="
|
||||
```
|
||||
|
||||
- [ ] **Step 5 :** `rtk pnpm vitest run server/templates/render.test.ts` reste vert. (pas de commit)
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Timeout d'inactivité SSH (additif)
|
||||
|
||||
**Files:** Modify `server/ssh/client.ts`.
|
||||
|
||||
- [ ] **Step 1 : Relire `server/ssh/client.ts`** (signatures `runScriptSudo`, `execStream`).
|
||||
|
||||
- [ ] **Step 2 : Ajouter un paramètre optionnel `inactivityTimeoutMs`** (défaut 0 = désactivé) à `runScriptSudo` et `execStream`. Dans `execStream`, armer un timer réinitialisé à chaque `data`/`stderr data` ; à expiration, `stream.close()`/`conn.end()` et `reject(new Error("human_interaction_required: aucune sortie depuis " + (ms/1000) + "s"))`.
|
||||
|
||||
```ts
|
||||
export async function runScriptSudo(
|
||||
creds: SshCreds,
|
||||
script: string,
|
||||
onData: (chunk: string) => void,
|
||||
inactivityTimeoutMs = 0,
|
||||
): Promise<RunResult> {
|
||||
const conn = await connect(creds);
|
||||
try {
|
||||
const b64 = Buffer.from(script, "utf8").toString("base64");
|
||||
const cmd = `sudo -S -p '' sh -c "$(printf '%s' '${b64}' | base64 -d)"`;
|
||||
return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData, inactivityTimeoutMs);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dans `execStream(conn, command, stdinData, onData, inactivityTimeoutMs = 0)` : après obtention du `stream`,
|
||||
```ts
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const arm = () => {
|
||||
if (!inactivityTimeoutMs) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
stream.close();
|
||||
reject(new Error(`human_interaction_required: aucune sortie depuis ${Math.round(inactivityTimeoutMs / 1000)}s`));
|
||||
}, inactivityTimeoutMs);
|
||||
};
|
||||
arm();
|
||||
```
|
||||
Réinitialiser `arm()` dans les handlers `data` et `stderr data` ; `clearTimeout(timer)` dans `close`. (Garder le `runPlain` existant inchangé : il appelle `execStream` sans le 5e arg ⇒ timeout 0.)
|
||||
|
||||
- [ ] **Step 3 :** `rtk pnpm check` → 0 erreur. (Pas de test unitaire SSH ; vérif manuelle en live ultérieure.) (pas de commit)
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Brancher les actions APT dans `runAction`
|
||||
|
||||
**Files:** Modify `server/services/execute.ts`.
|
||||
|
||||
- [ ] **Step 1 : Relire `server/services/execute.ts`** (TEMPLATE_FOR, flux, update executions, blocs Phase 1 reports/artifacts/state/event).
|
||||
|
||||
- [ ] **Step 2 : Étendre `TEMPLATE_FOR`**
|
||||
```ts
|
||||
const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
|
||||
apt_full_upgrade: "apt/full-upgrade.sh.tpl",
|
||||
apt_upgrade: "apt/upgrade.sh.tpl",
|
||||
apt_autoremove: "apt/autoremove.sh.tpl",
|
||||
apt_clean: "apt/clean.sh.tpl",
|
||||
reboot: "apt/reboot.sh.tpl",
|
||||
};
|
||||
```
|
||||
(Adapter l'accès : `const rel = TEMPLATE_FOR[action]; if (!rel) throw new Error("Action sans template: " + action);`)
|
||||
|
||||
- [ ] **Step 3 : Passer le timeout d'inactivité** pour les actions APT (pas pour reboot) :
|
||||
```ts
|
||||
const inactivity = action === "reboot" ? 0 : 600000;
|
||||
const res = await runScriptSudo(getCreds(m), script, (c) => { raw += c; outputHub.publish(machineId, c); }, inactivity);
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Construire `result.apt` (diff dpkg) pour les actions APT applicatives.** Après calcul de `raw` et avant l'écriture du rapport, ajouter :
|
||||
```ts
|
||||
let aptResult: AptExecutionResult | undefined;
|
||||
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
|
||||
aptResult = buildAptExecutionResult(
|
||||
extractSection(raw, "===SU:DPKG_BEFORE===", "==="), // jusqu'au prochain marqueur
|
||||
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
|
||||
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
|
||||
);
|
||||
}
|
||||
```
|
||||
> ⚠️ `extractSection(raw, "===SU:DPKG_BEFORE===", "===")` : le 2ᵉ marqueur générique `"==="` capture jusqu'au prochain `===SU:...===`. Vérifier que `extractSection` (dans `refresh.ts`) coupe bien au 1ᵉʳ `"==="` rencontré ; sinon, utiliser le marqueur réel suivant (`"===SU:APT_FULLUPGRADE==="` / `"===SU:APT_UPGRADE==="` / `"===SU:APT_AUTOREMOVE==="`). **Préférer** le marqueur explicite : détecter lequel est présent. Implémentation robuste :
|
||||
```ts
|
||||
const afterBeforeMarker =
|
||||
raw.includes("===SU:APT_FULLUPGRADE===") ? "===SU:APT_FULLUPGRADE===" :
|
||||
raw.includes("===SU:APT_UPGRADE===") ? "===SU:APT_UPGRADE===" :
|
||||
"===SU:APT_AUTOREMOVE===";
|
||||
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
|
||||
aptResult = buildAptExecutionResult(
|
||||
extractSection(raw, "===SU:DPKG_BEFORE===", afterBeforeMarker),
|
||||
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
|
||||
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Attacher `aptResult` au `ExecutionResult`** : dans la construction de `result`, ajouter `...(aptResult ? { apt: aptResult } : {})`. Importer en tête : `import { parseRebootRequired, extractSection } ...` (extractSection vient de `./refresh.js` — déjà importé) et `import { buildAptExecutionResult } from "./aptParse.js";` ainsi que `import type { AptExecutionResult } from "@shared/types.js";`.
|
||||
|
||||
- [ ] **Step 6 : Vérifier** — `rtk pnpm check && rtk pnpm test` → 0 erreur, tests verts. (pas de commit)
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Vérification finale SJ-2
|
||||
|
||||
- [ ] **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 ultérieure : `apt_full_upgrade` réel sur Debian → vérifier `result.apt.applied` (diff dpkg réel) + détection removed/held + comportement du timeout. **Ne pas committer.**
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (couverture SJ-2)
|
||||
- Templates `upgrade`/`full-upgrade` enrichi/`autoremove`/`clean` → Task 2. ✓
|
||||
- Capture `DPKG_BEFORE/AFTER` + diff réel (`AptExecutionResult`) → Task 1 + Task 4. ✓
|
||||
- Timeout d'inactivité + `human_interaction_required` → Task 3 (additif, off par défaut) + Task 4 (600s pour APT). ✓
|
||||
- Confirmations UI suppressions → hors périmètre (tâche 3) ; la donnée `removed[]` est exposée dans `result.apt`. ✓ (noté)
|
||||
- Non-régression : `apt_full_upgrade`/`reboot` jalon 1 conservés ; `runScriptSudo` rétro-compatible (timeout 0 par défaut) ; `ExecutionResult.apt` optionnel ; blocs Phase 1 préservés. ✓
|
||||
|
||||
Décision : `planned`/`held` laissés vides dans `AptExecutionResult` (portés par le snapshot SJ-1, pas re-simulés à l'exécution). `extractSection` utilisé avec marqueur explicite pour `DPKG_BEFORE`. Noms cohérents : `parseDpkgList`/`buildAptExecutionResult` (Task 1) utilisés Task 4 ; `inactivityTimeoutMs` (Task 3) passé Task 4.
|
||||
@@ -0,0 +1,252 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user