1e1be7f627
Ignore les dépôts de référence imbriqués (linux-update-dashboard, nas-ops). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2268 lines
70 KiB
Markdown
2268 lines
70 KiB
Markdown
# Jalon 1 — Tranche verticale APT — 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:** Ajouter une machine Debian/Ubuntu, rafraîchir ses mises à jour APT en tâche de fond, déclencher `full-upgrade`/`reboot` manuellement avec terminal live, et archiver un rapport Markdown.
|
|
|
|
**Architecture:** Mono-package pnpm. Backend Hono headless (cœur métier, seul à manipuler SSH et secrets) + worker in-process. SQLite via Drizzle. Les commandes APT vivent dans des templates shell `.sh.tpl` rendus en Mustache et poussés en SSH ; la sortie brute est parsée côté TypeScript en JSON canonique. Frontend React 19 + Vite consommant le design system Gruvbox, layout 3 volets (Hermes stub / dashboard / terminal xterm.js), flux live via WebSocket.
|
|
|
|
**Tech Stack:** TypeScript, Hono, @hono/node-server, Drizzle ORM + better-sqlite3, ssh2, ws, Mustache, croner, React 19, Vite, @xterm/xterm, vitest, Docker.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
```
|
|
system_update/
|
|
├─ package.json # mono-package (client + server)
|
|
├─ tsconfig.json
|
|
├─ vite.config.ts # client React
|
|
├─ vitest.config.ts
|
|
├─ tsup.config.ts # bundle server pour prod
|
|
├─ drizzle.config.ts
|
|
├─ .env.example
|
|
├─ shared/
|
|
│ └─ types.ts # types JSON canoniques partagés
|
|
├─ server/
|
|
│ ├─ index.ts # entrée serveur (Hono + node-server + WS + worker)
|
|
│ ├─ env.ts # lecture/validation des variables d'env
|
|
│ ├─ crypto/secrets.ts # AES-256-GCM encrypt/decrypt
|
|
│ ├─ db/
|
|
│ │ ├─ schema.ts # tables Drizzle
|
|
│ │ ├─ client.ts # instance Drizzle/SQLite
|
|
│ │ └─ migrate.ts # applique les migrations au démarrage
|
|
│ ├─ templates/
|
|
│ │ ├─ render.ts # rendu Mustache d'un .sh.tpl
|
|
│ │ └─ aptReduce.ts # réducteur déterministe de lignes APT
|
|
│ ├─ ssh/client.ts # wrapper ssh2 (password, sudo -S, streaming)
|
|
│ ├─ services/
|
|
│ │ ├─ aptParse.ts # parse sortie apt -s full-upgrade -> AptPackage[]
|
|
│ │ ├─ machines.ts # CRUD machines + test-connection + détection OS
|
|
│ │ ├─ refresh.ts # exécute check -> snapshot
|
|
│ │ ├─ execute.ts # exécute full-upgrade/reboot -> execution + report
|
|
│ │ └─ report.ts # génère le rapport Markdown
|
|
│ ├─ ws/outputHub.ts # buffers + broadcast par machine
|
|
│ ├─ jobs/worker.ts # planificateur in-process (croner)
|
|
│ └─ routes/
|
|
│ ├─ machines.ts
|
|
│ ├─ actions.ts
|
|
│ └─ index.ts # monte toutes les routes
|
|
├─ client/
|
|
│ ├─ index.html
|
|
│ └─ src/
|
|
│ ├─ main.tsx
|
|
│ ├─ App.tsx # layout 3 volets
|
|
│ ├─ styles/app.css
|
|
│ ├─ components/ # design system porté (Button, Popup, StatusLed, Icon…)
|
|
│ ├─ panels/
|
|
│ │ ├─ HermesPanel.tsx # stub
|
|
│ │ ├─ Dashboard.tsx
|
|
│ │ └─ TerminalPanel.tsx
|
|
│ ├─ features/machines/
|
|
│ │ ├─ MachineTile.tsx
|
|
│ │ └─ AddMachineModal.tsx
|
|
│ └─ lib/{api.ts,ws.ts}
|
|
├─ templates/apt/
|
|
│ ├─ check.sh.tpl
|
|
│ ├─ full-upgrade.sh.tpl
|
|
│ └─ reboot.sh.tpl
|
|
├─ reports/ # .gitkeep ; rapports + logs archivés (volume)
|
|
└─ docker/
|
|
├─ Dockerfile
|
|
└─ docker-compose.yml
|
|
```
|
|
|
|
---
|
|
|
|
## Task 0: Scaffolding du mono-package
|
|
|
|
**Files:**
|
|
- Create: `package.json`, `tsconfig.json`, `vitest.config.ts`, `.env.example`, `.gitignore`, `reports/.gitkeep`
|
|
|
|
- [ ] **Step 1: Créer `package.json`**
|
|
|
|
```json
|
|
{
|
|
"name": "system-update",
|
|
"version": "0.1.0",
|
|
"type": "module",
|
|
"packageManager": "pnpm@10.33.0",
|
|
"engines": { "node": ">=22" },
|
|
"scripts": {
|
|
"dev": "pnpm run dev:server & pnpm run dev:client",
|
|
"dev:server": "tsx watch server/index.ts",
|
|
"dev:client": "vite",
|
|
"build": "vite build && tsup",
|
|
"start": "node dist/index.js",
|
|
"test": "vitest run",
|
|
"check": "tsc --noEmit",
|
|
"db:generate": "drizzle-kit generate"
|
|
},
|
|
"dependencies": {
|
|
"@hono/node-server": "^1.13.0",
|
|
"better-sqlite3": "^11.8.0",
|
|
"croner": "^9.0.0",
|
|
"drizzle-orm": "^0.38.0",
|
|
"hono": "^4.6.0",
|
|
"mustache": "^4.2.0",
|
|
"ssh2": "^1.16.0",
|
|
"ws": "^8.18.0"
|
|
},
|
|
"devDependencies": {
|
|
"@types/better-sqlite3": "^7.6.12",
|
|
"@types/mustache": "^4.2.5",
|
|
"@types/node": "^22.10.0",
|
|
"@types/react": "^19.0.0",
|
|
"@types/react-dom": "^19.0.0",
|
|
"@types/ssh2": "^1.15.1",
|
|
"@types/ws": "^8.5.13",
|
|
"@vitejs/plugin-react": "^4.3.4",
|
|
"@xterm/addon-fit": "^0.10.0",
|
|
"@xterm/xterm": "^5.5.0",
|
|
"drizzle-kit": "^0.30.0",
|
|
"react": "^19.0.0",
|
|
"react-dom": "^19.0.0",
|
|
"tsup": "^8.3.5",
|
|
"tsx": "^4.19.2",
|
|
"typescript": "^5.7.0",
|
|
"vite": "^6.0.0",
|
|
"vitest": "^2.1.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Créer `tsconfig.json`**
|
|
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"target": "ES2022",
|
|
"module": "ESNext",
|
|
"moduleResolution": "Bundler",
|
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
"jsx": "react-jsx",
|
|
"strict": true,
|
|
"noUncheckedIndexedAccess": true,
|
|
"esModuleInterop": true,
|
|
"skipLibCheck": true,
|
|
"resolveJsonModule": true,
|
|
"types": ["node", "vitest/globals"],
|
|
"paths": { "@shared/*": ["./shared/*"] },
|
|
"baseUrl": "."
|
|
},
|
|
"include": ["server", "client", "shared", "*.ts"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Créer `vitest.config.ts`**
|
|
|
|
```ts
|
|
import { defineConfig } from "vitest/config";
|
|
|
|
export default defineConfig({
|
|
test: {
|
|
globals: true,
|
|
include: ["server/**/*.test.ts", "shared/**/*.test.ts"],
|
|
environment: "node",
|
|
},
|
|
resolve: { alias: { "@shared": new URL("./shared", import.meta.url).pathname } },
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Créer `.env.example`**
|
|
|
|
```sh
|
|
# Clé maître de chiffrement des credentials (32 octets en hex = 64 caractères).
|
|
# Générer avec: openssl rand -hex 32
|
|
SU_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
|
# Chemin du fichier SQLite
|
|
SU_DB_PATH=./data/system-update.db
|
|
# Répertoire d'archivage des rapports + logs
|
|
SU_REPORTS_DIR=./reports
|
|
# Port HTTP du serveur
|
|
SU_PORT=8787
|
|
```
|
|
|
|
- [ ] **Step 5: Créer `.gitignore` et `reports/.gitkeep`**
|
|
|
|
`.gitignore`:
|
|
```
|
|
node_modules/
|
|
dist/
|
|
data/
|
|
.env
|
|
reports/*
|
|
!reports/.gitkeep
|
|
```
|
|
`reports/.gitkeep`: fichier vide.
|
|
|
|
- [ ] **Step 6: Installer les dépendances**
|
|
|
|
Run: `pnpm install`
|
|
Expected: installation sans erreur, `node_modules/` créé.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git checkout -b jalon1-apt
|
|
git add package.json tsconfig.json vitest.config.ts .env.example .gitignore reports/.gitkeep pnpm-lock.yaml
|
|
git commit -m "chore: scaffolding mono-package jalon 1 (APT)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: Types JSON canoniques partagés
|
|
|
|
**Files:**
|
|
- Create: `shared/types.ts`
|
|
|
|
- [ ] **Step 1: Écrire les types**
|
|
|
|
```ts
|
|
// shared/types.ts
|
|
export type OsFamily = "debian" | "ubuntu" | "unknown";
|
|
export type MachineStatus = "unknown" | "ok" | "updates_available" | "error" | "running";
|
|
export type AptProxyMode = "direct" | "runtime";
|
|
export type ActionType = "apt_full_upgrade" | "reboot";
|
|
export type ExecutionStatus = "ok" | "warning" | "error";
|
|
|
|
export interface AptPackage {
|
|
name: string;
|
|
currentVersion: string | null;
|
|
targetVersion: string;
|
|
origin: string | null;
|
|
}
|
|
|
|
export interface UpdateSnapshot {
|
|
machineId: string;
|
|
hostname: string;
|
|
os: { family: OsFamily; version: string };
|
|
checkedAt: string; // ISO 8601
|
|
status: MachineStatus;
|
|
apt: {
|
|
enabled: boolean;
|
|
count: number;
|
|
rebootRequired: boolean;
|
|
packages: AptPackage[];
|
|
};
|
|
rawHints?: { logImportantLines: string[] };
|
|
}
|
|
|
|
export interface ExecutionResult {
|
|
executionId: string;
|
|
machineId: string;
|
|
startedAt: string;
|
|
finishedAt: string;
|
|
mode: "manual";
|
|
action: ActionType;
|
|
status: ExecutionStatus;
|
|
rebootRequiredAfterRun: boolean;
|
|
importantLogLines: string[];
|
|
rawLogRef: string;
|
|
reportRef: string;
|
|
}
|
|
|
|
/** Vue machine renvoyée par l'API — NE CONTIENT JAMAIS de secret. */
|
|
export interface MachineView {
|
|
id: string;
|
|
name: string;
|
|
hostname: string;
|
|
port: number;
|
|
osFamily: OsFamily;
|
|
username: string;
|
|
aptProxyMode: AptProxyMode;
|
|
aptProxyUrl: string | null;
|
|
status: MachineStatus;
|
|
lastCheckedAt: string | null;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add shared/types.ts
|
|
git commit -m "feat: types JSON canoniques partagés (snapshot, execution, machine)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Chiffrement des secrets (AES-256-GCM) — TDD
|
|
|
|
**Files:**
|
|
- Create: `server/env.ts`, `server/crypto/secrets.ts`, `server/crypto/secrets.test.ts`
|
|
|
|
- [ ] **Step 1: Créer `server/env.ts`**
|
|
|
|
```ts
|
|
// server/env.ts
|
|
function required(name: string): string {
|
|
const v = process.env[name];
|
|
if (!v) throw new Error(`Variable d'environnement manquante: ${name}`);
|
|
return v;
|
|
}
|
|
|
|
export const env = {
|
|
masterKeyHex: process.env.SU_MASTER_KEY ?? "",
|
|
dbPath: process.env.SU_DB_PATH ?? "./data/system-update.db",
|
|
reportsDir: process.env.SU_REPORTS_DIR ?? "./reports",
|
|
port: Number(process.env.SU_PORT ?? 8787),
|
|
requireMasterKey(): string {
|
|
const k = required("SU_MASTER_KEY");
|
|
if (k.length !== 64) throw new Error("SU_MASTER_KEY doit faire 64 caractères hex (32 octets).");
|
|
return k;
|
|
},
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Écrire le test (échec attendu)**
|
|
|
|
```ts
|
|
// server/crypto/secrets.test.ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { encryptSecret, decryptSecret } from "./secrets.js";
|
|
|
|
const KEY = "a".repeat(64); // 32 octets en hex
|
|
|
|
describe("secrets", () => {
|
|
it("round-trip encrypt/decrypt restitue le texte clair", () => {
|
|
const blob = encryptSecret("hunter2", KEY);
|
|
expect(blob).not.toContain("hunter2");
|
|
expect(decryptSecret(blob, KEY)).toBe("hunter2");
|
|
});
|
|
|
|
it("produit un blob différent à chaque chiffrement (IV aléatoire)", () => {
|
|
expect(encryptSecret("x", KEY)).not.toBe(encryptSecret("x", KEY));
|
|
});
|
|
|
|
it("échoue si le blob a été altéré (tag GCM)", () => {
|
|
const blob = encryptSecret("secret", KEY);
|
|
const tampered = blob.slice(0, -2) + (blob.endsWith("a") ? "b" : "a");
|
|
expect(() => decryptSecret(tampered, KEY)).toThrow();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Lancer le test (échec)**
|
|
|
|
Run: `pnpm vitest run server/crypto/secrets.test.ts`
|
|
Expected: FAIL — module `./secrets.js` introuvable.
|
|
|
|
- [ ] **Step 4: Implémenter `server/crypto/secrets.ts`**
|
|
|
|
```ts
|
|
// server/crypto/secrets.ts
|
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
|
|
const ALGO = "aes-256-gcm";
|
|
|
|
/** Chiffre une chaîne. Format de sortie: base64(iv).base64(tag).base64(ciphertext). */
|
|
export function encryptSecret(plaintext: string, keyHex: string): string {
|
|
const key = Buffer.from(keyHex, "hex");
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv(ALGO, key, iv);
|
|
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return [iv.toString("base64"), tag.toString("base64"), ct.toString("base64")].join(".");
|
|
}
|
|
|
|
export function decryptSecret(blob: string, keyHex: string): string {
|
|
const key = Buffer.from(keyHex, "hex");
|
|
const [ivB64, tagB64, ctB64] = blob.split(".");
|
|
if (!ivB64 || !tagB64 || !ctB64) throw new Error("Blob chiffré invalide");
|
|
const decipher = createDecipheriv(ALGO, key, Buffer.from(ivB64, "base64"));
|
|
decipher.setAuthTag(Buffer.from(tagB64, "base64"));
|
|
return Buffer.concat([decipher.update(Buffer.from(ctB64, "base64")), decipher.final()]).toString("utf8");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Lancer le test (succès)**
|
|
|
|
Run: `pnpm vitest run server/crypto/secrets.test.ts`
|
|
Expected: PASS (3 tests).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add server/env.ts server/crypto/secrets.ts server/crypto/secrets.test.ts
|
|
git commit -m "feat: chiffrement AES-256-GCM des secrets + lecture env
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Schéma de base de données (Drizzle / SQLite)
|
|
|
|
**Files:**
|
|
- Create: `drizzle.config.ts`, `server/db/schema.ts`, `server/db/client.ts`, `server/db/migrate.ts`
|
|
|
|
- [ ] **Step 1: Créer `server/db/schema.ts`**
|
|
|
|
```ts
|
|
// server/db/schema.ts
|
|
import { sqliteTable, text, integer } 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"),
|
|
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"),
|
|
createdAt: text("created_at").notNull(),
|
|
});
|
|
|
|
export const snapshots = sqliteTable("snapshots", {
|
|
id: text("id").primaryKey(),
|
|
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
|
|
checkedAt: text("checked_at").notNull(),
|
|
status: text("status").notNull(),
|
|
payloadJson: text("payload_json").notNull(),
|
|
});
|
|
|
|
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"),
|
|
startedAt: text("started_at").notNull(),
|
|
finishedAt: text("finished_at"),
|
|
status: text("status").notNull(),
|
|
resultJson: text("result_json"),
|
|
reportPath: text("report_path"),
|
|
rawLogPath: text("raw_log_path"),
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Créer `drizzle.config.ts`**
|
|
|
|
```ts
|
|
import { defineConfig } from "drizzle-kit";
|
|
|
|
export default defineConfig({
|
|
dialect: "sqlite",
|
|
schema: "./server/db/schema.ts",
|
|
out: "./server/db/migrations",
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Créer `server/db/client.ts`**
|
|
|
|
```ts
|
|
// server/db/client.ts
|
|
import Database from "better-sqlite3";
|
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
import { mkdirSync } from "node:fs";
|
|
import { dirname } from "node:path";
|
|
import { env } from "../env.js";
|
|
import * as schema from "./schema.js";
|
|
|
|
mkdirSync(dirname(env.dbPath), { recursive: true });
|
|
const sqlite = new Database(env.dbPath);
|
|
sqlite.pragma("journal_mode = WAL");
|
|
sqlite.pragma("foreign_keys = ON");
|
|
|
|
export const db = drizzle(sqlite, { schema });
|
|
export { schema };
|
|
```
|
|
|
|
- [ ] **Step 4: Générer la migration initiale**
|
|
|
|
Run: `pnpm db:generate`
|
|
Expected: un fichier SQL créé dans `server/db/migrations/`.
|
|
|
|
- [ ] **Step 5: Créer `server/db/migrate.ts`**
|
|
|
|
```ts
|
|
// server/db/migrate.ts
|
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
import { db } from "./client.js";
|
|
|
|
export function runMigrations(): void {
|
|
migrate(db, { migrationsFolder: "./server/db/migrations" });
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add drizzle.config.ts server/db/
|
|
git commit -m "feat: schéma Drizzle/SQLite (machines, snapshots, executions)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Réducteur déterministe de lignes APT — TDD
|
|
|
|
**Files:**
|
|
- Create: `server/templates/aptReduce.ts`, `server/templates/aptReduce.test.ts`
|
|
|
|
- [ ] **Step 1: Écrire le test (échec attendu)**
|
|
|
|
```ts
|
|
// server/templates/aptReduce.test.ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { reduceAptLines } from "./aptReduce.js";
|
|
|
|
describe("reduceAptLines", () => {
|
|
it("ne garde que les lignes utiles", () => {
|
|
const raw = [
|
|
"Reading package lists...",
|
|
"Building dependency tree...",
|
|
"Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])",
|
|
"blabla inutile",
|
|
"Conf pve-manager (8.4-3 Proxmox VE:8.x [amd64])",
|
|
"E: Could not get lock",
|
|
"W: Some warning",
|
|
"dpkg: warning: x",
|
|
].join("\n");
|
|
expect(reduceAptLines(raw)).toEqual([
|
|
"Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])",
|
|
"Conf pve-manager (8.4-3 Proxmox VE:8.x [amd64])",
|
|
"E: Could not get lock",
|
|
"W: Some warning",
|
|
"dpkg: warning: x",
|
|
]);
|
|
});
|
|
|
|
it("retourne un tableau vide si rien d'utile", () => {
|
|
expect(reduceAptLines("Reading package lists...\nDone")).toEqual([]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (échec)**
|
|
|
|
Run: `pnpm vitest run server/templates/aptReduce.test.ts`
|
|
Expected: FAIL — module introuvable.
|
|
|
|
- [ ] **Step 3: Implémenter `server/templates/aptReduce.ts`**
|
|
|
|
```ts
|
|
// server/templates/aptReduce.ts
|
|
const PREFIXES = ["Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:"];
|
|
const CONTAINS = ["reboot-required", "REBOOT_REQUIRED"];
|
|
|
|
/** Garde uniquement les lignes informatives d'une sortie APT brute. */
|
|
export function reduceAptLines(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)));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer le test (succès)**
|
|
|
|
Run: `pnpm vitest run server/templates/aptReduce.test.ts`
|
|
Expected: PASS (2 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add server/templates/aptReduce.ts server/templates/aptReduce.test.ts
|
|
git commit -m "feat: réducteur déterministe de lignes APT
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Parser de sortie `apt-get -s full-upgrade` — TDD
|
|
|
|
**Files:**
|
|
- Create: `server/services/aptParse.ts`, `server/services/aptParse.test.ts`, `server/services/__fixtures__/apt-simulate.txt`
|
|
|
|
- [ ] **Step 1: Créer la fixture `server/services/__fixtures__/apt-simulate.txt`**
|
|
|
|
```
|
|
Reading package lists...
|
|
Building dependency tree...
|
|
Reading state information...
|
|
Calculating upgrade...
|
|
The following packages will be upgraded:
|
|
libc6 pve-manager
|
|
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
|
|
Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])
|
|
Inst newpkg (1.0.0 Debian:11.6/stable [all])
|
|
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
|
|
Conf pve-manager (8.4-3 Proxmox VE:8.x [amd64])
|
|
```
|
|
|
|
- [ ] **Step 2: Écrire le test (échec attendu)**
|
|
|
|
```ts
|
|
// server/services/aptParse.test.ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { parseAptSimulate, parseRebootRequired } from "./aptParse.js";
|
|
|
|
const raw = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-simulate.txt", import.meta.url)), "utf8");
|
|
|
|
describe("parseAptSimulate", () => {
|
|
it("extrait les paquets upgradables avec versions et origine", () => {
|
|
const pkgs = parseAptSimulate(raw);
|
|
expect(pkgs).toEqual([
|
|
{ name: "libc6", currentVersion: "2.31-13", targetVersion: "2.31-13+deb11u5", origin: "Debian:11.6/stable" },
|
|
{ name: "pve-manager", currentVersion: "8.4-1", targetVersion: "8.4-3", origin: "Proxmox VE:8.x" },
|
|
{ name: "newpkg", currentVersion: null, targetVersion: "1.0.0", origin: "Debian:11.6/stable" },
|
|
]);
|
|
});
|
|
|
|
it("retourne un tableau vide quand aucun Inst", () => {
|
|
expect(parseAptSimulate("Reading package lists...\nDone")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("parseRebootRequired", () => {
|
|
it("détecte le marqueur REBOOT_REQUIRED=1", () => {
|
|
expect(parseRebootRequired("REBOOT_REQUIRED=1")).toBe(true);
|
|
expect(parseRebootRequired("REBOOT_REQUIRED=0")).toBe(false);
|
|
expect(parseRebootRequired("rien")).toBe(false);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Lancer le test (échec)**
|
|
|
|
Run: `pnpm vitest run server/services/aptParse.test.ts`
|
|
Expected: FAIL — module introuvable.
|
|
|
|
- [ ] **Step 4: Implémenter `server/services/aptParse.ts`**
|
|
|
|
```ts
|
|
// server/services/aptParse.ts
|
|
import type { AptPackage } from "@shared/types.js";
|
|
|
|
// Exemple de ligne:
|
|
// Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])
|
|
// Inst newpkg (1.0.0 Debian:11.6/stable [all])
|
|
const INST_RE = /^Inst (\S+) (?:\[([^\]]+)\] )?\((\S+) (.+?) \[[^\]]+\]\)\s*$/;
|
|
|
|
export function parseAptSimulate(raw: string): AptPackage[] {
|
|
const out: AptPackage[] = [];
|
|
for (const line of raw.split("\n")) {
|
|
const m = INST_RE.exec(line.trimEnd());
|
|
if (!m) continue;
|
|
out.push({
|
|
name: m[1]!,
|
|
currentVersion: m[2] ?? null,
|
|
targetVersion: m[3]!,
|
|
origin: (m[4] ?? "").trim() || null,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function parseRebootRequired(raw: string): boolean {
|
|
return /REBOOT_REQUIRED=1/.test(raw);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Lancer le test (succès)**
|
|
|
|
Run: `pnpm vitest run server/services/aptParse.test.ts`
|
|
Expected: PASS (3 tests).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add server/services/aptParse.ts server/services/aptParse.test.ts server/services/__fixtures__/
|
|
git commit -m "feat: parser sortie apt-get -s full-upgrade -> AptPackage[]
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Templates shell APT + rendu Mustache — TDD
|
|
|
|
**Files:**
|
|
- Create: `templates/apt/check.sh.tpl`, `templates/apt/full-upgrade.sh.tpl`, `templates/apt/reboot.sh.tpl`, `server/templates/render.ts`, `server/templates/render.test.ts`
|
|
|
|
- [ ] **Step 1: Créer `templates/apt/check.sh.tpl`**
|
|
|
|
```sh
|
|
#!/bin/sh
|
|
# Rendu sans sudo: le script entier est exécuté sous sudo par la couche SSH.
|
|
export LC_ALL=C
|
|
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
|
|
{{/aptProxy}}
|
|
echo "===SU:UPDATE==="
|
|
apt-get update -qq 2>&1
|
|
echo "===SU:SIMULATE==="
|
|
apt-get -s -y full-upgrade 2>&1
|
|
echo "===SU:REBOOT==="
|
|
if [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
|
|
echo "===SU:END==="
|
|
```
|
|
|
|
- [ ] **Step 2: Créer `templates/apt/full-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:UPGRADE==="
|
|
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade 2>&1
|
|
CODE=$?
|
|
echo "===SU:REBOOT==="
|
|
if [ -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/reboot.sh.tpl`**
|
|
|
|
```sh
|
|
#!/bin/sh
|
|
export LC_ALL=C
|
|
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 4: Écrire le test (échec attendu)**
|
|
|
|
```ts
|
|
// server/templates/render.test.ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { renderTemplate } from "./render.js";
|
|
|
|
describe("renderTemplate", () => {
|
|
it("rend check.sh.tpl sans proxy", () => {
|
|
const out = renderTemplate("apt/check.sh.tpl", { aptProxy: null });
|
|
expect(out).toContain("apt-get update -qq");
|
|
expect(out).toContain("===SU:SIMULATE===");
|
|
expect(out).not.toContain("http_proxy");
|
|
});
|
|
|
|
it("injecte le proxy quand fourni", () => {
|
|
const out = renderTemplate("apt/check.sh.tpl", { aptProxy: "http://cache:3142" });
|
|
expect(out).toContain('http_proxy="http://cache:3142"');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Lancer le test (échec)**
|
|
|
|
Run: `pnpm vitest run server/templates/render.test.ts`
|
|
Expected: FAIL — module introuvable.
|
|
|
|
- [ ] **Step 6: Implémenter `server/templates/render.ts`**
|
|
|
|
```ts
|
|
// server/templates/render.ts
|
|
import Mustache from "mustache";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
const TEMPLATES_ROOT = resolve(process.cwd(), "templates");
|
|
|
|
export interface TemplateVars {
|
|
aptProxy?: string | null;
|
|
}
|
|
|
|
export function renderTemplate(relPath: string, vars: TemplateVars): string {
|
|
const tpl = readFileSync(resolve(TEMPLATES_ROOT, relPath), "utf8");
|
|
// Mustache échappe le HTML par défaut; on désactive (ce sont des scripts shell).
|
|
return Mustache.render(tpl, vars, {}, { escape: (s) => s });
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Lancer le test (succès)**
|
|
|
|
Run: `pnpm vitest run server/templates/render.test.ts`
|
|
Expected: PASS (2 tests).
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add templates/apt/ server/templates/render.ts server/templates/render.test.ts
|
|
git commit -m "feat: templates shell APT + rendu Mustache
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Couche SSH (ssh2, password, sudo -S, streaming)
|
|
|
|
**Files:**
|
|
- Create: `server/ssh/client.ts`
|
|
|
|
> Note: la couche SSH réelle est validée manuellement (Task 18). Pas de test unitaire (pas de mock SSH au MVP).
|
|
|
|
- [ ] **Step 1: Implémenter `server/ssh/client.ts`**
|
|
|
|
```ts
|
|
// server/ssh/client.ts
|
|
import { Client } from "ssh2";
|
|
|
|
export interface SshCreds {
|
|
hostname: string;
|
|
port: number;
|
|
username: string;
|
|
password: string;
|
|
sudoPassword?: string | null;
|
|
}
|
|
|
|
export interface RunResult {
|
|
stdout: string;
|
|
code: number;
|
|
}
|
|
|
|
function connect(creds: SshCreds): Promise<Client> {
|
|
return new Promise((resolve, reject) => {
|
|
const conn = new Client();
|
|
conn
|
|
.on("ready", () => resolve(conn))
|
|
.on("error", reject)
|
|
.connect({
|
|
host: creds.hostname,
|
|
port: creds.port,
|
|
username: creds.username,
|
|
password: creds.password,
|
|
readyTimeout: 15000,
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Exécute une commande simple (sans sudo), renvoie stdout agrégé. */
|
|
export async function runPlain(creds: SshCreds, command: string): Promise<RunResult> {
|
|
const conn = await connect(creds);
|
|
try {
|
|
return await execStream(conn, command, null, () => {});
|
|
} finally {
|
|
conn.end();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exécute un script shell sous sudo. Le script est encodé en base64 pour éviter
|
|
* tout problème de quoting; le mot de passe sudo est poussé sur stdin (sudo -S -p '').
|
|
* `onData` reçoit chaque chunk de sortie pour le streaming live.
|
|
*/
|
|
export async function runScriptSudo(
|
|
creds: SshCreds,
|
|
script: string,
|
|
onData: (chunk: string) => void,
|
|
): 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);
|
|
} finally {
|
|
conn.end();
|
|
}
|
|
}
|
|
|
|
function execStream(
|
|
conn: Client,
|
|
command: string,
|
|
stdinData: string | null,
|
|
onData: (chunk: string) => void,
|
|
): Promise<RunResult> {
|
|
return new Promise((resolve, reject) => {
|
|
conn.exec(command, { pty: false }, (err, stream) => {
|
|
if (err) return reject(err);
|
|
let stdout = "";
|
|
let code = 0;
|
|
if (stdinData) {
|
|
stream.write(stdinData);
|
|
}
|
|
stream
|
|
.on("close", (c: number) => resolve({ stdout, code: c ?? code }))
|
|
.on("data", (d: Buffer) => {
|
|
const s = d.toString("utf8");
|
|
stdout += s;
|
|
onData(s);
|
|
});
|
|
stream.stderr.on("data", (d: Buffer) => {
|
|
const s = d.toString("utf8");
|
|
stdout += s;
|
|
onData(s);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Vérifier la compilation**
|
|
|
|
Run: `pnpm check`
|
|
Expected: PASS (aucune erreur TypeScript).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add server/ssh/client.ts
|
|
git commit -m "feat: couche SSH (password, sudo -S, exec streaming)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Hub de sortie WebSocket (buffer + broadcast)
|
|
|
|
**Files:**
|
|
- Create: `server/ws/outputHub.ts`, `server/ws/outputHub.test.ts`
|
|
|
|
- [ ] **Step 1: Écrire le test (échec attendu)**
|
|
|
|
```ts
|
|
// server/ws/outputHub.test.ts
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { OutputHub } from "./outputHub.js";
|
|
|
|
describe("OutputHub", () => {
|
|
it("rejoue le buffer aux nouveaux abonnés", () => {
|
|
const hub = new OutputHub();
|
|
hub.publish("m1", "ligne1");
|
|
hub.publish("m1", "ligne2");
|
|
const received: string[] = [];
|
|
hub.subscribe("m1", (c) => received.push(c));
|
|
expect(received).toEqual(["ligne1", "ligne2"]);
|
|
});
|
|
|
|
it("diffuse les nouveaux chunks aux abonnés", () => {
|
|
const hub = new OutputHub();
|
|
const fn = vi.fn();
|
|
hub.subscribe("m1", fn);
|
|
hub.publish("m1", "x");
|
|
expect(fn).toHaveBeenCalledWith("x");
|
|
});
|
|
|
|
it("vide le buffer avec clear()", () => {
|
|
const hub = new OutputHub();
|
|
hub.publish("m1", "old");
|
|
hub.clear("m1");
|
|
const received: string[] = [];
|
|
hub.subscribe("m1", (c) => received.push(c));
|
|
expect(received).toEqual([]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (échec)**
|
|
|
|
Run: `pnpm vitest run server/ws/outputHub.test.ts`
|
|
Expected: FAIL — module introuvable.
|
|
|
|
- [ ] **Step 3: Implémenter `server/ws/outputHub.ts`**
|
|
|
|
```ts
|
|
// server/ws/outputHub.ts
|
|
type Listener = (chunk: string) => void;
|
|
const MAX_BUFFER = 5000; // lignes/chunks gardés pour le rejeu
|
|
|
|
export class OutputHub {
|
|
private buffers = new Map<string, string[]>();
|
|
private listeners = new Map<string, Set<Listener>>();
|
|
|
|
publish(machineId: string, chunk: string): void {
|
|
const buf = this.buffers.get(machineId) ?? [];
|
|
buf.push(chunk);
|
|
if (buf.length > MAX_BUFFER) buf.shift();
|
|
this.buffers.set(machineId, buf);
|
|
this.listeners.get(machineId)?.forEach((l) => l(chunk));
|
|
}
|
|
|
|
/** S'abonne et reçoit immédiatement le buffer existant (rejeu). */
|
|
subscribe(machineId: string, listener: Listener): () => void {
|
|
(this.buffers.get(machineId) ?? []).forEach((c) => listener(c));
|
|
const set = this.listeners.get(machineId) ?? new Set();
|
|
set.add(listener);
|
|
this.listeners.set(machineId, set);
|
|
return () => set.delete(listener);
|
|
}
|
|
|
|
clear(machineId: string): void {
|
|
this.buffers.delete(machineId);
|
|
}
|
|
}
|
|
|
|
export const outputHub = new OutputHub();
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer le test (succès)**
|
|
|
|
Run: `pnpm vitest run server/ws/outputHub.test.ts`
|
|
Expected: PASS (3 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add server/ws/outputHub.ts server/ws/outputHub.test.ts
|
|
git commit -m "feat: hub de sortie WebSocket avec buffer rejouable
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Service machines (CRUD + test-connection + détection OS)
|
|
|
|
**Files:**
|
|
- Create: `server/services/machines.ts`
|
|
|
|
- [ ] **Step 1: Implémenter `server/services/machines.ts`**
|
|
|
|
```ts
|
|
// server/services/machines.ts
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { db, schema } from "../db/client.js";
|
|
import { encryptSecret, decryptSecret } from "../crypto/secrets.js";
|
|
import { env } from "../env.js";
|
|
import { runPlain, type SshCreds } from "../ssh/client.js";
|
|
import type { MachineView, OsFamily } from "@shared/types.js";
|
|
|
|
export interface CreateMachineInput {
|
|
name: string;
|
|
hostname: string;
|
|
port: number;
|
|
username: string;
|
|
password: string;
|
|
sudoPassword?: string | null;
|
|
aptProxyMode?: "direct" | "runtime";
|
|
aptProxyUrl?: string | null;
|
|
}
|
|
|
|
type MachineRow = typeof schema.machines.$inferSelect;
|
|
|
|
function toView(m: MachineRow): MachineView {
|
|
return {
|
|
id: m.id,
|
|
name: m.name,
|
|
hostname: m.hostname,
|
|
port: m.port,
|
|
osFamily: m.osFamily as OsFamily,
|
|
username: m.username,
|
|
aptProxyMode: m.aptProxyMode as "direct" | "runtime",
|
|
aptProxyUrl: m.aptProxyUrl,
|
|
status: m.status as MachineView["status"],
|
|
lastCheckedAt: m.lastCheckedAt,
|
|
};
|
|
}
|
|
|
|
export function getCreds(m: MachineRow): SshCreds {
|
|
const key = env.requireMasterKey();
|
|
return {
|
|
hostname: m.hostname,
|
|
port: m.port,
|
|
username: m.username,
|
|
password: decryptSecret(m.encPassword, key),
|
|
sudoPassword: m.encSudoPassword ? decryptSecret(m.encSudoPassword, key) : null,
|
|
};
|
|
}
|
|
|
|
export function getMachineRow(id: string): MachineRow | undefined {
|
|
return db.select().from(schema.machines).where(eq(schema.machines.id, id)).get();
|
|
}
|
|
|
|
export function listMachines(): MachineView[] {
|
|
return db.select().from(schema.machines).all().map(toView);
|
|
}
|
|
|
|
/** Parse /etc/os-release pour déduire family + version. */
|
|
export function parseOsRelease(content: string): { family: OsFamily; version: string } {
|
|
const fields: Record<string, string> = {};
|
|
for (const line of content.split("\n")) {
|
|
const m = /^([A-Z_]+)=(.*)$/.exec(line.trim());
|
|
if (m) fields[m[1]!] = m[2]!.replace(/^"|"$/g, "");
|
|
}
|
|
const id = (fields.ID ?? "").toLowerCase();
|
|
const family: OsFamily = id === "ubuntu" ? "ubuntu" : id === "debian" ? "debian" : "unknown";
|
|
return { family, version: fields.VERSION_ID ?? fields.VERSION ?? "" };
|
|
}
|
|
|
|
export async function testConnection(creds: SshCreds): Promise<{ family: OsFamily; version: string }> {
|
|
const res = await runPlain(creds, "cat /etc/os-release");
|
|
return parseOsRelease(res.stdout);
|
|
}
|
|
|
|
export async function createMachine(input: CreateMachineInput): Promise<MachineView> {
|
|
const key = env.requireMasterKey();
|
|
const creds: SshCreds = {
|
|
hostname: input.hostname,
|
|
port: input.port,
|
|
username: input.username,
|
|
password: input.password,
|
|
sudoPassword: input.sudoPassword ?? null,
|
|
};
|
|
const os = await testConnection(creds); // lève si la connexion échoue
|
|
const id = randomUUID();
|
|
const row: MachineRow = {
|
|
id,
|
|
name: input.name,
|
|
hostname: input.hostname,
|
|
port: input.port,
|
|
osFamily: os.family,
|
|
username: input.username,
|
|
encPassword: encryptSecret(input.password, key),
|
|
encSudoPassword: input.sudoPassword ? encryptSecret(input.sudoPassword, key) : null,
|
|
aptProxyMode: input.aptProxyMode ?? "direct",
|
|
aptProxyUrl: input.aptProxyUrl ?? null,
|
|
status: "unknown",
|
|
lastCheckedAt: null,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
db.insert(schema.machines).values(row).run();
|
|
return toView(row);
|
|
}
|
|
|
|
export function deleteMachine(id: string): void {
|
|
db.delete(schema.machines).where(eq(schema.machines.id, id)).run();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Écrire un test pour `parseOsRelease`**
|
|
|
|
```ts
|
|
// server/services/machines.test.ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { parseOsRelease } from "./machines.js";
|
|
|
|
describe("parseOsRelease", () => {
|
|
it("détecte Debian", () => {
|
|
const r = parseOsRelease('ID=debian\nVERSION_ID="11"\nPRETTY_NAME="Debian 11"');
|
|
expect(r).toEqual({ family: "debian", version: "11" });
|
|
});
|
|
it("détecte Ubuntu", () => {
|
|
const r = parseOsRelease('ID=ubuntu\nVERSION_ID="22.04"');
|
|
expect(r).toEqual({ family: "ubuntu", version: "22.04" });
|
|
});
|
|
it("retombe sur unknown", () => {
|
|
expect(parseOsRelease("ID=arch").family).toBe("unknown");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Lancer le test**
|
|
|
|
Run: `pnpm vitest run server/services/machines.test.ts`
|
|
Expected: PASS (3 tests).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add server/services/machines.ts server/services/machines.test.ts
|
|
git commit -m "feat: service machines (CRUD, test-connection, détection OS)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Service refresh (check APT -> snapshot)
|
|
|
|
**Files:**
|
|
- Create: `server/services/refresh.ts`
|
|
|
|
- [ ] **Step 1: Implémenter `server/services/refresh.ts`**
|
|
|
|
```ts
|
|
// server/services/refresh.ts
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { db, schema } from "../db/client.js";
|
|
import { getMachineRow, getCreds } from "./machines.js";
|
|
import { renderTemplate } from "../templates/render.js";
|
|
import { reduceAptLines } from "../templates/aptReduce.js";
|
|
import { runScriptSudo } from "../ssh/client.js";
|
|
import { parseAptSimulate, parseRebootRequired } from "./aptParse.js";
|
|
import { outputHub } from "../ws/outputHub.js";
|
|
import type { UpdateSnapshot, MachineStatus } from "@shared/types.js";
|
|
|
|
/** Extrait la section entre deux marqueurs ===SU:X=== d'une sortie de script. */
|
|
export function extractSection(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();
|
|
}
|
|
|
|
export async function refreshMachine(machineId: string): Promise<UpdateSnapshot> {
|
|
const m = getMachineRow(machineId);
|
|
if (!m) throw new Error("Machine introuvable");
|
|
|
|
db.update(schema.machines).set({ status: "running" }).where(eq(schema.machines.id, machineId)).run();
|
|
outputHub.clear(machineId);
|
|
|
|
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
|
|
const script = renderTemplate("apt/check.sh.tpl", { 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 simulate = extractSection(raw, "===SU:SIMULATE===", "===SU:REBOOT===");
|
|
const rebootSection = extractSection(raw, "===SU:REBOOT===", "===SU:END===");
|
|
const packages = parseAptSimulate(simulate);
|
|
const rebootRequired = parseRebootRequired(rebootSection);
|
|
const status: MachineStatus = packages.length > 0 ? "updates_available" : "ok";
|
|
const checkedAt = new Date().toISOString();
|
|
|
|
const snapshot: UpdateSnapshot = {
|
|
machineId,
|
|
hostname: m.hostname,
|
|
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: "" },
|
|
checkedAt,
|
|
status,
|
|
apt: { enabled: true, count: packages.length, rebootRequired, packages },
|
|
rawHints: { logImportantLines: reduceAptLines(raw) },
|
|
};
|
|
|
|
db.insert(schema.snapshots).values({
|
|
id: randomUUID(),
|
|
machineId,
|
|
checkedAt,
|
|
status,
|
|
payloadJson: JSON.stringify(snapshot),
|
|
}).run();
|
|
db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run();
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
export function getLatestSnapshot(machineId: string): UpdateSnapshot | null {
|
|
const row = db.select().from(schema.snapshots).where(eq(schema.snapshots.machineId, machineId)).all().at(-1);
|
|
return row ? (JSON.parse(row.payloadJson) as UpdateSnapshot) : null;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Écrire un test pour `extractSection`**
|
|
|
|
```ts
|
|
// server/services/refresh.test.ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { extractSection } from "./refresh.js";
|
|
|
|
const raw = "===SU:UPDATE===\nok\n===SU:SIMULATE===\nInst a [1] (2 X [amd64])\n===SU:REBOOT===\nREBOOT_REQUIRED=0\n===SU:END===";
|
|
|
|
describe("extractSection", () => {
|
|
it("extrait le contenu entre deux marqueurs", () => {
|
|
expect(extractSection(raw, "===SU:SIMULATE===", "===SU:REBOOT===")).toBe("Inst a [1] (2 X [amd64])");
|
|
});
|
|
it("retourne vide si marqueur absent", () => {
|
|
expect(extractSection(raw, "===SU:NOPE===", "===SU:END===")).toBe("");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Lancer le test**
|
|
|
|
Run: `pnpm vitest run server/services/refresh.test.ts`
|
|
Expected: PASS (2 tests).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add server/services/refresh.ts server/services/refresh.test.ts
|
|
git commit -m "feat: service refresh (check APT -> snapshot canonique)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Service report (Markdown) — TDD
|
|
|
|
**Files:**
|
|
- Create: `server/services/report.ts`, `server/services/report.test.ts`
|
|
|
|
- [ ] **Step 1: Écrire le test (échec attendu)**
|
|
|
|
```ts
|
|
// server/services/report.test.ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { buildReportMarkdown } from "./report.js";
|
|
import type { ExecutionResult } from "@shared/types.js";
|
|
|
|
const exec: ExecutionResult = {
|
|
executionId: "exec_1", machineId: "m1", startedAt: "2026-06-04T12:00:00Z",
|
|
finishedAt: "2026-06-04T12:05:00Z", mode: "manual", action: "apt_full_upgrade",
|
|
status: "ok", rebootRequiredAfterRun: true,
|
|
importantLogLines: ["Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian [amd64])"],
|
|
rawLogRef: "reports/m1/exec_1.log", reportRef: "reports/m1/exec_1.md",
|
|
};
|
|
|
|
describe("buildReportMarkdown", () => {
|
|
it("contient l'en-tête, le statut et les lignes importantes", () => {
|
|
const md = buildReportMarkdown(exec, "deb-01");
|
|
expect(md).toContain("# Rapport d'exécution — deb-01");
|
|
expect(md).toContain("apt_full_upgrade");
|
|
expect(md).toContain("Redémarrage requis : oui");
|
|
expect(md).toContain("Inst libc6");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (échec)**
|
|
|
|
Run: `pnpm vitest run server/services/report.test.ts`
|
|
Expected: FAIL — module introuvable.
|
|
|
|
- [ ] **Step 3: Implémenter `server/services/report.ts`**
|
|
|
|
```ts
|
|
// server/services/report.ts
|
|
import type { ExecutionResult } from "@shared/types.js";
|
|
|
|
export function buildReportMarkdown(exec: ExecutionResult, machineName: string): string {
|
|
const lines = exec.importantLogLines.map((l) => ` ${l}`).join("\n");
|
|
return `# Rapport d'exécution — ${machineName}
|
|
|
|
- **Exécution** : ${exec.executionId}
|
|
- **Action** : ${exec.action}
|
|
- **Statut** : ${exec.status}
|
|
- **Début** : ${exec.startedAt}
|
|
- **Fin** : ${exec.finishedAt}
|
|
- **Redémarrage requis** : ${exec.rebootRequiredAfterRun ? "oui" : "non"}
|
|
|
|
## Lignes importantes
|
|
|
|
\`\`\`
|
|
${lines || " (aucune)"}
|
|
\`\`\`
|
|
|
|
## Log brut
|
|
|
|
Référence : \`${exec.rawLogRef}\`
|
|
`;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer le test (succès)**
|
|
|
|
Run: `pnpm vitest run server/services/report.test.ts`
|
|
Expected: PASS (1 test).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add server/services/report.ts server/services/report.test.ts
|
|
git commit -m "feat: génération de rapport Markdown d'exécution
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Service execute (full-upgrade / reboot -> execution + report)
|
|
|
|
**Files:**
|
|
- Create: `server/services/execute.ts`
|
|
|
|
- [ ] **Step 1: Implémenter `server/services/execute.ts`**
|
|
|
|
```ts
|
|
// server/services/execute.ts
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { db, schema } from "../db/client.js";
|
|
import { env } from "../env.js";
|
|
import { getMachineRow, getCreds } from "./machines.js";
|
|
import { renderTemplate } from "../templates/render.js";
|
|
import { reduceAptLines } from "../templates/aptReduce.js";
|
|
import { runScriptSudo } from "../ssh/client.js";
|
|
import { parseRebootRequired } from "./aptParse.js";
|
|
import { extractSection } from "./refresh.js";
|
|
import { buildReportMarkdown } from "./report.js";
|
|
import { outputHub } from "../ws/outputHub.js";
|
|
import type { ActionType, ExecutionResult, ExecutionStatus } from "@shared/types.js";
|
|
|
|
const TEMPLATE_FOR: Record<ActionType, string> = {
|
|
apt_full_upgrade: "apt/full-upgrade.sh.tpl",
|
|
reboot: "apt/reboot.sh.tpl",
|
|
};
|
|
|
|
export async function runAction(machineId: string, action: ActionType): Promise<ExecutionResult> {
|
|
const m = getMachineRow(machineId);
|
|
if (!m) throw new Error("Machine introuvable");
|
|
|
|
const executionId = `exec_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
|
const startedAt = new Date().toISOString();
|
|
outputHub.clear(machineId);
|
|
db.update(schema.machines).set({ status: "running" }).where(eq(schema.machines.id, machineId)).run();
|
|
db.insert(schema.executions).values({
|
|
id: executionId, machineId, action, mode: "manual", startedAt, status: "running",
|
|
}).run();
|
|
|
|
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
|
|
const script = renderTemplate(TEMPLATE_FOR[action], { aptProxy: proxy });
|
|
|
|
let raw = "";
|
|
let status: ExecutionStatus = "ok";
|
|
try {
|
|
const res = await runScriptSudo(getCreds(m), script, (c) => {
|
|
raw += c;
|
|
outputHub.publish(machineId, c);
|
|
});
|
|
raw = res.stdout;
|
|
if (/===SU:EXIT=(\d+)===/.exec(raw)?.[1] && /===SU:EXIT=0===/.test(raw) === false) {
|
|
status = "error";
|
|
}
|
|
} catch (err) {
|
|
status = "error";
|
|
raw += `\n[ERREUR] ${(err as Error).message}\n`;
|
|
}
|
|
|
|
const finishedAt = new Date().toISOString();
|
|
const rebootRequired = parseRebootRequired(extractSection(raw, "===SU:REBOOT===", "===SU:EXIT") || raw);
|
|
|
|
// Archivage log brut + rapport.
|
|
const dir = join(env.reportsDir, machineId);
|
|
mkdirSync(dir, { recursive: true });
|
|
const rawLogPath = join(dir, `${executionId}.log`);
|
|
const reportPath = join(dir, `${executionId}.md`);
|
|
writeFileSync(rawLogPath, raw, "utf8");
|
|
|
|
const result: ExecutionResult = {
|
|
executionId, machineId, startedAt, finishedAt, mode: "manual", action, status,
|
|
rebootRequiredAfterRun: rebootRequired,
|
|
importantLogLines: reduceAptLines(raw),
|
|
rawLogRef: rawLogPath, reportRef: reportPath,
|
|
};
|
|
writeFileSync(reportPath, buildReportMarkdown(result, m.name), "utf8");
|
|
|
|
db.update(schema.executions).set({
|
|
finishedAt, status, resultJson: JSON.stringify(result), reportPath, rawLogPath,
|
|
}).where(eq(schema.executions.id, executionId)).run();
|
|
db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" })
|
|
.where(eq(schema.machines.id, machineId)).run();
|
|
|
|
outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`);
|
|
return result;
|
|
}
|
|
|
|
export function listExecutions(machineId: string) {
|
|
return db.select().from(schema.executions).where(eq(schema.executions.machineId, machineId)).all();
|
|
}
|
|
|
|
export function getExecution(executionId: string) {
|
|
return db.select().from(schema.executions).where(eq(schema.executions.id, executionId)).get();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Vérifier la compilation**
|
|
|
|
Run: `pnpm check`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add server/services/execute.ts
|
|
git commit -m "feat: service execute (full-upgrade/reboot -> execution + rapport archivé)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Worker in-process (refresh périodique)
|
|
|
|
**Files:**
|
|
- Create: `server/jobs/worker.ts`
|
|
|
|
- [ ] **Step 1: Implémenter `server/jobs/worker.ts`**
|
|
|
|
```ts
|
|
// server/jobs/worker.ts
|
|
import { Cron } from "croner";
|
|
import { listMachines } from "../services/machines.js";
|
|
import { refreshMachine } from "../services/refresh.js";
|
|
|
|
let job: Cron | null = null;
|
|
|
|
/** Rafraîchit toutes les machines toutes les 30 minutes (tâche de fond). */
|
|
export function startWorker(): void {
|
|
job = new Cron("*/30 * * * *", async () => {
|
|
for (const m of listMachines()) {
|
|
try {
|
|
await refreshMachine(m.id);
|
|
} catch (err) {
|
|
console.error(`[worker] refresh échoué pour ${m.id}:`, (err as Error).message);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export function stopWorker(): void {
|
|
job?.stop();
|
|
job = null;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Vérifier la compilation**
|
|
|
|
Run: `pnpm check`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add server/jobs/worker.ts
|
|
git commit -m "feat: worker in-process de refresh périodique (croner)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Routes HTTP (Hono)
|
|
|
|
**Files:**
|
|
- Create: `server/routes/machines.ts`, `server/routes/actions.ts`, `server/routes/index.ts`
|
|
|
|
- [ ] **Step 1: Implémenter `server/routes/machines.ts`**
|
|
|
|
```ts
|
|
// server/routes/machines.ts
|
|
import { Hono } from "hono";
|
|
import {
|
|
listMachines, createMachine, deleteMachine, getMachineRow, getCreds, testConnection,
|
|
type CreateMachineInput,
|
|
} from "../services/machines.js";
|
|
import { refreshMachine, getLatestSnapshot } from "../services/refresh.js";
|
|
|
|
export const machinesRoutes = new Hono();
|
|
|
|
machinesRoutes.get("/", (c) => c.json(listMachines()));
|
|
|
|
machinesRoutes.post("/", async (c) => {
|
|
const body = (await c.req.json()) as CreateMachineInput;
|
|
try {
|
|
const m = await createMachine(body);
|
|
return c.json(m, 201);
|
|
} catch (err) {
|
|
return c.json({ error: `Connexion échouée: ${(err as Error).message}` }, 400);
|
|
}
|
|
});
|
|
|
|
machinesRoutes.post("/:id/test-connection", async (c) => {
|
|
const m = getMachineRow(c.req.param("id"));
|
|
if (!m) return c.json({ error: "Machine introuvable" }, 404);
|
|
try {
|
|
return c.json(await testConnection(getCreds(m)));
|
|
} catch (err) {
|
|
return c.json({ error: (err as Error).message }, 400);
|
|
}
|
|
});
|
|
|
|
machinesRoutes.get("/:id/snapshot", (c) => {
|
|
const snap = getLatestSnapshot(c.req.param("id"));
|
|
return snap ? c.json(snap) : c.json({ error: "Aucun snapshot" }, 404);
|
|
});
|
|
|
|
machinesRoutes.post("/:id/refresh", async (c) => {
|
|
try {
|
|
return c.json(await refreshMachine(c.req.param("id")));
|
|
} catch (err) {
|
|
return c.json({ error: (err as Error).message }, 400);
|
|
}
|
|
});
|
|
|
|
machinesRoutes.delete("/:id", (c) => {
|
|
deleteMachine(c.req.param("id"));
|
|
return c.json({ ok: true });
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Implémenter `server/routes/actions.ts`**
|
|
|
|
```ts
|
|
// server/routes/actions.ts
|
|
import { Hono } from "hono";
|
|
import { readFileSync } from "node:fs";
|
|
import { runAction, listExecutions, getExecution } from "../services/execute.js";
|
|
import type { ActionType } from "@shared/types.js";
|
|
|
|
export const actionsRoutes = new Hono();
|
|
|
|
actionsRoutes.post("/:id/actions", async (c) => {
|
|
const { action } = (await c.req.json()) as { action: ActionType };
|
|
if (action !== "apt_full_upgrade" && action !== "reboot") {
|
|
return c.json({ error: "Action non autorisée" }, 400);
|
|
}
|
|
// Exécution lancée en arrière-plan; le suivi se fait via WebSocket.
|
|
runAction(c.req.param("id"), action).catch((err) =>
|
|
console.error("[action]", (err as Error).message),
|
|
);
|
|
return c.json({ ok: true, action }, 202);
|
|
});
|
|
|
|
actionsRoutes.get("/:id/executions", (c) => c.json(listExecutions(c.req.param("id"))));
|
|
|
|
actionsRoutes.get("/:id/executions/:execId", (c) => {
|
|
const e = getExecution(c.req.param("execId"));
|
|
return e ? c.json(e) : c.json({ error: "Exécution introuvable" }, 404);
|
|
});
|
|
|
|
actionsRoutes.get("/:id/executions/:execId/report", (c) => {
|
|
const e = getExecution(c.req.param("execId"));
|
|
if (!e?.reportPath) return c.json({ error: "Rapport introuvable" }, 404);
|
|
return c.text(readFileSync(e.reportPath, "utf8"));
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Implémenter `server/routes/index.ts`**
|
|
|
|
```ts
|
|
// server/routes/index.ts
|
|
import { Hono } from "hono";
|
|
import { machinesRoutes } from "./machines.js";
|
|
import { actionsRoutes } from "./actions.js";
|
|
|
|
export const api = new Hono();
|
|
api.route("/machines", machinesRoutes);
|
|
api.route("/machines", actionsRoutes);
|
|
```
|
|
|
|
- [ ] **Step 4: Vérifier la compilation**
|
|
|
|
Run: `pnpm check`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add server/routes/
|
|
git commit -m "feat: routes HTTP Hono (machines, refresh, actions, executions)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Entrée serveur (Hono + node-server + WebSocket + worker)
|
|
|
|
**Files:**
|
|
- Create: `server/index.ts`
|
|
|
|
- [ ] **Step 1: Implémenter `server/index.ts`**
|
|
|
|
```ts
|
|
// server/index.ts
|
|
import { serve } from "@hono/node-server";
|
|
import { Hono } from "hono";
|
|
import { WebSocketServer } from "ws";
|
|
import type { IncomingMessage } from "node:http";
|
|
import { env } from "./env.js";
|
|
import { runMigrations } from "./db/migrate.js";
|
|
import { api } from "./routes/index.js";
|
|
import { outputHub } from "./ws/outputHub.js";
|
|
import { startWorker } from "./jobs/worker.js";
|
|
|
|
env.requireMasterKey();
|
|
runMigrations();
|
|
|
|
const app = new Hono();
|
|
app.route("/api", api);
|
|
app.get("/health", (c) => c.json({ ok: true }));
|
|
|
|
const server = serve({ fetch: app.fetch, port: env.port }, (info) =>
|
|
console.log(`[server] http://localhost:${info.port}`),
|
|
);
|
|
|
|
// WebSocket: /api/ws/machines/:id/output
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
server.on("upgrade", (req: IncomingMessage, socket, head) => {
|
|
const match = /^\/api\/ws\/machines\/([^/]+)\/output$/.exec(req.url ?? "");
|
|
if (!match) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
const machineId = match[1]!;
|
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
const unsub = outputHub.subscribe(machineId, (chunk) => {
|
|
if (ws.readyState === ws.OPEN) ws.send(chunk);
|
|
});
|
|
ws.on("close", unsub);
|
|
});
|
|
});
|
|
|
|
startWorker();
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le serveur**
|
|
|
|
Run: `SU_MASTER_KEY=$(openssl rand -hex 32) pnpm dev:server`
|
|
Expected: log `[server] http://localhost:8787`. Vérifier `curl localhost:8787/health` → `{"ok":true}`. Arrêter (Ctrl-C).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add server/index.ts
|
|
git commit -m "feat: entrée serveur (Hono + WebSocket /api/ws + worker)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 16: Scaffolding client (Vite + React + design system)
|
|
|
|
**Files:**
|
|
- Create: `vite.config.ts`, `client/index.html`, `client/src/main.tsx`, `client/src/styles/app.css`
|
|
- Copy: `design_system/tokens/tokens.css` → `client/src/styles/tokens.css`
|
|
- Port: `design_system/components/ui-kit.jsx` → `client/src/components/ui-kit.tsx`
|
|
|
|
- [ ] **Step 1: Créer `vite.config.ts`**
|
|
|
|
```ts
|
|
import { defineConfig } from "vite";
|
|
import react from "@vitejs/plugin-react";
|
|
|
|
export default defineConfig({
|
|
root: "client",
|
|
plugins: [react()],
|
|
resolve: { alias: { "@shared": new URL("./shared", import.meta.url).pathname } },
|
|
server: { proxy: { "/api": { target: "http://localhost:8787", ws: true } } },
|
|
build: { outDir: "../dist/client", emptyOutDir: true },
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Créer `client/index.html`**
|
|
|
|
```html
|
|
<!doctype html>
|
|
<html lang="fr" data-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>System Update</title>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="module" src="/src/main.tsx"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 3: Copier les tokens et porter le ui-kit**
|
|
|
|
```bash
|
|
cp design_system/tokens/tokens.css client/src/styles/tokens.css
|
|
cp design_system/components/ui-kit.jsx client/src/components/ui-kit.tsx
|
|
```
|
|
Puis dans `client/src/components/ui-kit.tsx`, ajouter en tête `// @ts-nocheck` (le design system est en JS non typé ; on le consomme tel quel pour le MVP) et vérifier que les imports React sont présents (`import React from "react";`).
|
|
|
|
- [ ] **Step 4: Créer `client/src/styles/app.css`**
|
|
|
|
```css
|
|
@import "./tokens.css";
|
|
|
|
* { box-sizing: border-box; }
|
|
html, body, #root { height: 100%; margin: 0; }
|
|
body {
|
|
font-family: var(--font-ui);
|
|
background: var(--bg-1);
|
|
color: var(--ink-1);
|
|
}
|
|
.su-layout { display: flex; height: 100vh; }
|
|
.su-hermes { width: 280px; min-width: 200px; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; }
|
|
.su-center { flex: 1; overflow: auto; padding: 18px; }
|
|
.su-terminal { width: 360px; min-width: 320px; background: var(--bg-0); border-left: 1px solid var(--border-1); }
|
|
.su-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
|
```
|
|
|
|
- [ ] **Step 5: Créer `client/src/main.tsx`**
|
|
|
|
```tsx
|
|
import React from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import "./styles/app.css";
|
|
import { App } from "./App.js";
|
|
|
|
createRoot(document.getElementById("root")!).render(
|
|
<React.StrictMode>
|
|
<App />
|
|
</React.StrictMode>,
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add vite.config.ts client/index.html client/src/main.tsx client/src/styles/ client/src/components/ui-kit.tsx
|
|
git commit -m "feat: scaffolding client Vite/React + design system Gruvbox
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 17: Librairies client (API + WebSocket)
|
|
|
|
**Files:**
|
|
- Create: `client/src/lib/api.ts`, `client/src/lib/ws.ts`
|
|
|
|
- [ ] **Step 1: Créer `client/src/lib/api.ts`**
|
|
|
|
```ts
|
|
// client/src/lib/api.ts
|
|
import type { MachineView, UpdateSnapshot, ActionType } from "@shared/types.js";
|
|
|
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(`/api${path}`, {
|
|
headers: { "content-type": "application/json" },
|
|
...init,
|
|
});
|
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
export const api = {
|
|
listMachines: () => req<MachineView[]>("/machines"),
|
|
createMachine: (body: unknown) => req<MachineView>("/machines", { method: "POST", body: JSON.stringify(body) }),
|
|
refresh: (id: string) => req<UpdateSnapshot>(`/machines/${id}/refresh`, { method: "POST" }),
|
|
snapshot: (id: string) => req<UpdateSnapshot>(`/machines/${id}/snapshot`),
|
|
runAction: (id: string, action: ActionType) =>
|
|
req<{ ok: boolean }>(`/machines/${id}/actions`, { method: "POST", body: JSON.stringify({ action }) }),
|
|
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Créer `client/src/lib/ws.ts`**
|
|
|
|
```ts
|
|
// client/src/lib/ws.ts
|
|
export function connectOutput(machineId: string, onChunk: (s: string) => void): () => void {
|
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
const ws = new WebSocket(`${proto}://${location.host}/api/ws/machines/${machineId}/output`);
|
|
ws.onmessage = (ev) => onChunk(typeof ev.data === "string" ? ev.data : "");
|
|
return () => ws.close();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Vérifier la compilation**
|
|
|
|
Run: `pnpm check`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add client/src/lib/
|
|
git commit -m "feat: librairies client API REST + WebSocket
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 18: Composants UI (layout, tuiles, modale, terminal)
|
|
|
|
**Files:**
|
|
- Create: `client/src/App.tsx`, `client/src/panels/HermesPanel.tsx`, `client/src/panels/Dashboard.tsx`, `client/src/panels/TerminalPanel.tsx`, `client/src/features/machines/MachineTile.tsx`, `client/src/features/machines/AddMachineModal.tsx`
|
|
|
|
- [ ] **Step 1: Créer `client/src/panels/HermesPanel.tsx` (stub)**
|
|
|
|
```tsx
|
|
// client/src/panels/HermesPanel.tsx
|
|
export function HermesPanel() {
|
|
return (
|
|
<aside className="su-hermes">
|
|
<div className="label" style={{ marginBottom: 12 }}>HERMES</div>
|
|
<p style={{ color: "var(--ink-3)", fontSize: 13 }}>
|
|
Copilote d'exploitation — à venir. Analyse des mises à jour, plans et rapports
|
|
seront disponibles ici dans un prochain jalon.
|
|
</p>
|
|
</aside>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Créer `client/src/features/machines/MachineTile.tsx`**
|
|
|
|
```tsx
|
|
// client/src/features/machines/MachineTile.tsx
|
|
import type { MachineView } from "@shared/types.js";
|
|
|
|
interface Props {
|
|
machine: MachineView;
|
|
packageCount: number;
|
|
onSelect: (id: string) => void;
|
|
onRefresh: (id: string) => void;
|
|
onUpgrade: (id: string) => void;
|
|
onReboot: (id: string) => void;
|
|
}
|
|
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
ok: "var(--ok)", updates_available: "var(--warn)", error: "var(--err)",
|
|
running: "var(--info)", unknown: "var(--ink-4)",
|
|
};
|
|
|
|
export function MachineTile({ machine, packageCount, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
|
|
return (
|
|
<div className="glass" style={{ padding: 16, borderRadius: 10 }} onClick={() => onSelect(machine.id)}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<span style={{ width: 10, height: 10, borderRadius: 999, background: STATUS_COLOR[machine.status] }} />
|
|
<strong>{machine.name}</strong>
|
|
</div>
|
|
<div className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
|
|
{machine.hostname}:{machine.port} · {machine.osFamily}
|
|
</div>
|
|
<div style={{ margin: "10px 0", fontSize: 13 }}>
|
|
<span className="label">UPDATES</span>{" "}
|
|
<span className="mono">{packageCount}</span>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }} onClick={(e) => e.stopPropagation()}>
|
|
<button className="interactive" onClick={() => onRefresh(machine.id)}>Refresh</button>
|
|
<button className="interactive" onClick={() => onUpgrade(machine.id)}>Upgrade</button>
|
|
<button className="interactive" onClick={() => onReboot(machine.id)}>Reboot</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
> Note de design system : remplacer les `<button className="interactive">` par le composant `<Button>` de `components/ui-kit.tsx` une fois son API confirmée (variants `primary`/`ghost`/`danger`). Les `IconButton` isolés doivent recevoir un `label` (tooltip). Aucun hover, pression 3D uniquement.
|
|
|
|
- [ ] **Step 3: Créer `client/src/features/machines/AddMachineModal.tsx`**
|
|
|
|
```tsx
|
|
// client/src/features/machines/AddMachineModal.tsx
|
|
import { useState } from "react";
|
|
|
|
interface Props { onClose: () => void; onCreated: () => void; }
|
|
|
|
export function AddMachineModal({ onClose, onCreated }: Props) {
|
|
const [form, setForm] = useState({ name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "" });
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const set = (k: string, v: string | number) => setForm({ ...form, [k]: v });
|
|
|
|
async function submit() {
|
|
setBusy(true); setError(null);
|
|
try {
|
|
const res = await fetch("/api/machines", {
|
|
method: "POST", headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null }),
|
|
});
|
|
if (!res.ok) throw new Error((await res.json()).error ?? "Échec");
|
|
onCreated(); onClose();
|
|
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
|
}
|
|
|
|
return (
|
|
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.5)", display: "grid", placeItems: "center" }}>
|
|
<div className="glass-strong" style={{ padding: 20, borderRadius: 12, width: 380, display: "grid", gap: 10 }}>
|
|
<div className="label">AJOUTER UNE MACHINE</div>
|
|
{(["name", "hostname", "username"] as const).map((k) => (
|
|
<input key={k} placeholder={k} value={form[k]} onChange={(e) => set(k, e.target.value)} />
|
|
))}
|
|
<input placeholder="port" type="number" value={form.port} onChange={(e) => set("port", e.target.value)} />
|
|
<input placeholder="password" type="password" value={form.password} onChange={(e) => set("password", e.target.value)} />
|
|
<input placeholder="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} />
|
|
{error && <div style={{ color: "var(--err)", fontSize: 12 }}>{error}</div>}
|
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
|
<button onClick={onClose}>Annuler</button>
|
|
<button className="interactive" disabled={busy} onClick={submit}>{busy ? "Test…" : "Ajouter"}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Créer `client/src/panels/TerminalPanel.tsx`**
|
|
|
|
```tsx
|
|
// client/src/panels/TerminalPanel.tsx
|
|
import { useEffect, useRef } from "react";
|
|
import { Terminal } from "@xterm/xterm";
|
|
import { FitAddon } from "@xterm/addon-fit";
|
|
import "@xterm/xterm/css/xterm.css";
|
|
import { connectOutput } from "../lib/ws.js";
|
|
|
|
export function TerminalPanel({ machineId }: { machineId: string | null }) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!ref.current) return;
|
|
const term = new Terminal({
|
|
fontFamily: "'Share Tech Mono', monospace", fontSize: 12,
|
|
theme: { background: "#1d2021", foreground: "#ebdbb2" },
|
|
convertEol: true,
|
|
});
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
term.open(ref.current);
|
|
fit.fit();
|
|
term.writeln(machineId ? `# flux ${machineId}` : "# sélectionne une machine");
|
|
const disconnect = machineId ? connectOutput(machineId, (c) => term.write(c)) : () => {};
|
|
return () => { disconnect(); term.dispose(); };
|
|
}, [machineId]);
|
|
|
|
return <div className="su-terminal" ref={ref} style={{ padding: 6 }} />;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Créer `client/src/panels/Dashboard.tsx`**
|
|
|
|
```tsx
|
|
// client/src/panels/Dashboard.tsx
|
|
import { useEffect, useState } from "react";
|
|
import type { MachineView } from "@shared/types.js";
|
|
import { api } from "../lib/api.js";
|
|
import { MachineTile } from "../features/machines/MachineTile.js";
|
|
import { AddMachineModal } from "../features/machines/AddMachineModal.js";
|
|
|
|
interface Props { onSelect: (id: string) => void; }
|
|
|
|
export function Dashboard({ onSelect }: Props) {
|
|
const [machines, setMachines] = useState<MachineView[]>([]);
|
|
const [counts, setCounts] = useState<Record<string, number>>({});
|
|
const [adding, setAdding] = useState(false);
|
|
|
|
async function load() {
|
|
const ms = await api.listMachines();
|
|
setMachines(ms);
|
|
const entries = await Promise.all(ms.map(async (m) => {
|
|
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
|
|
catch { return [m.id, 0] as const; }
|
|
}));
|
|
setCounts(Object.fromEntries(entries));
|
|
}
|
|
useEffect(() => { void load(); }, []);
|
|
|
|
return (
|
|
<main className="su-center">
|
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
|
|
<h2 style={{ margin: 0 }}>Machines</h2>
|
|
<button className="interactive" onClick={() => setAdding(true)}>+ Ajouter</button>
|
|
</div>
|
|
{machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
|
|
<div className="su-tiles">
|
|
{machines.map((m) => (
|
|
<MachineTile
|
|
key={m.id} machine={m} packageCount={counts[m.id] ?? 0} onSelect={onSelect}
|
|
onRefresh={(id) => { onSelect(id); void api.refresh(id).then(load); }}
|
|
onUpgrade={(id) => { onSelect(id); void api.runAction(id, "apt_full_upgrade"); }}
|
|
onReboot={(id) => { onSelect(id); void api.runAction(id, "reboot"); }}
|
|
/>
|
|
))}
|
|
</div>
|
|
{adding && <AddMachineModal onClose={() => setAdding(false)} onCreated={load} />}
|
|
</main>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Créer `client/src/App.tsx`**
|
|
|
|
```tsx
|
|
// client/src/App.tsx
|
|
import { useState } from "react";
|
|
import { HermesPanel } from "./panels/HermesPanel.js";
|
|
import { Dashboard } from "./panels/Dashboard.js";
|
|
import { TerminalPanel } from "./panels/TerminalPanel.js";
|
|
|
|
export function App() {
|
|
const [selected, setSelected] = useState<string | null>(null);
|
|
return (
|
|
<div className="su-layout">
|
|
<HermesPanel />
|
|
<Dashboard onSelect={setSelected} />
|
|
<TerminalPanel machineId={selected} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Vérifier la compilation + build client**
|
|
|
|
Run: `pnpm check && pnpm vite build`
|
|
Expected: PASS, bundle produit dans `dist/client`.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add client/src/App.tsx client/src/panels/ client/src/features/
|
|
git commit -m "feat: UI 3 volets (Hermes stub, dashboard tuiles, terminal xterm.js)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 19: Vérification end-to-end manuelle (machine réelle)
|
|
|
|
**Files:** aucun (validation manuelle).
|
|
|
|
- [ ] **Step 1: Lancer l'app complète**
|
|
|
|
Run:
|
|
```bash
|
|
export SU_MASTER_KEY=$(openssl rand -hex 32)
|
|
pnpm dev
|
|
```
|
|
Expected: serveur sur `:8787`, client Vite sur `:5173` (proxy `/api` + ws actif).
|
|
|
|
- [ ] **Step 2: Ajouter une vraie machine Debian/Ubuntu**
|
|
|
|
Dans le navigateur, « + Ajouter » → renseigner host/user/password/sudo. Attendu : la tuile apparaît (test-connection a réussi, OS détecté).
|
|
|
|
- [ ] **Step 3: Refresh**
|
|
|
|
Cliquer Refresh. Attendu : compteur d'updates mis à jour, sortie visible dans le terminal de droite, snapshot persisté (`GET /api/machines/:id/snapshot`).
|
|
|
|
- [ ] **Step 4: Upgrade + rapport**
|
|
|
|
Cliquer Upgrade. Attendu : sortie live dans le terminal, puis `===SU:DONE===`. Vérifier qu'un `.md` et un `.log` existent dans `reports/<id>/` et que `GET /api/machines/:id/executions` liste l'exécution.
|
|
|
|
- [ ] **Step 5: Vérifier l'absence de secret**
|
|
|
|
Inspecter la base (`reports`, réponses API, logs serveur) : aucun mot de passe en clair (`enc_password` est un blob `iv.tag.ct`).
|
|
|
|
- [ ] **Step 6: Suite de tests complète**
|
|
|
|
Run: `pnpm test`
|
|
Expected: PASS (crypto, aptReduce, aptParse, render, outputHub, machines, refresh, report).
|
|
|
|
- [ ] **Step 7: Commit final du jalon**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "test: vérification end-to-end jalon 1 (APT)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 20: Packaging Docker
|
|
|
|
**Files:**
|
|
- Create: `tsup.config.ts`, `docker/Dockerfile`, `docker/docker-compose.yml`
|
|
|
|
- [ ] **Step 1: Créer `tsup.config.ts`**
|
|
|
|
```ts
|
|
import { defineConfig } from "tsup";
|
|
|
|
export default defineConfig({
|
|
entry: ["server/index.ts"],
|
|
outDir: "dist",
|
|
format: ["esm"],
|
|
platform: "node",
|
|
target: "node22",
|
|
bundle: true,
|
|
noExternal: [/.*/],
|
|
external: ["better-sqlite3"],
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Créer `docker/Dockerfile`**
|
|
|
|
```dockerfile
|
|
FROM node:22-bookworm-slim AS build
|
|
WORKDIR /app
|
|
RUN corepack enable
|
|
COPY package.json pnpm-lock.yaml ./
|
|
RUN pnpm install --frozen-lockfile
|
|
COPY . .
|
|
RUN pnpm build
|
|
|
|
FROM node:22-bookworm-slim
|
|
WORKDIR /app
|
|
RUN corepack enable
|
|
COPY package.json pnpm-lock.yaml ./
|
|
RUN pnpm install --frozen-lockfile --prod
|
|
COPY --from=build /app/dist ./dist
|
|
COPY --from=build /app/server/db/migrations ./server/db/migrations
|
|
COPY --from=build /app/templates ./templates
|
|
ENV SU_DB_PATH=/data/system-update.db
|
|
ENV SU_REPORTS_DIR=/reports
|
|
EXPOSE 8787
|
|
CMD ["node", "dist/index.js"]
|
|
```
|
|
|
|
> Note: `dist/client` (front statique) peut être servi par un reverse proxy ou ajouté via `hono/serve-static`. Pour le MVP, le front est buildé et servi séparément ; le service du statique côté Hono est un suivi optionnel.
|
|
|
|
- [ ] **Step 3: Créer `docker/docker-compose.yml`**
|
|
|
|
```yaml
|
|
services:
|
|
system-update:
|
|
build:
|
|
context: ..
|
|
dockerfile: docker/Dockerfile
|
|
ports:
|
|
- "8787:8787"
|
|
environment:
|
|
SU_MASTER_KEY: ${SU_MASTER_KEY:?définir SU_MASTER_KEY}
|
|
volumes:
|
|
- su-data:/data
|
|
- su-reports:/reports
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
su-data:
|
|
su-reports:
|
|
```
|
|
|
|
- [ ] **Step 4: Vérifier le build de l'image**
|
|
|
|
Run: `SU_MASTER_KEY=$(openssl rand -hex 32) docker compose -f docker/docker-compose.yml build`
|
|
Expected: image construite sans erreur.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tsup.config.ts docker/
|
|
git commit -m "feat: packaging Docker (Dockerfile + compose, volumes data/reports)
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review (couverture du spec)
|
|
|
|
- **Ajout machine + test-connection** → Task 9 (`createMachine`/`testConnection`), Task 14 (routes), Task 18 (modale). ✓
|
|
- **Credentials chiffrés, aucun secret exposé** → Task 2 (crypto), Task 9 (stockage `enc_*`, `MachineView` sans secret), Task 19 step 5. ✓
|
|
- **Refresh de fond → snapshot canonique** → Task 10 (service), Task 13 (worker), Task 5/6 (parse/template). ✓
|
|
- **Tuile : nom, IP, OS, compteur, paquets** → Task 18 (`MachineTile`, `Dashboard`). ✓
|
|
- **full-upgrade live terminal** → Task 12 (execute + outputHub), Task 8 (hub), Task 15 (WS), Task 18 (TerminalPanel). ✓
|
|
- **reboot manuel** → Task 6 (`reboot.sh.tpl`), Task 12 (`TEMPLATE_FOR`), Task 14 (actions). ✓
|
|
- **Rapport .md + log brut archivés** → Task 11 (markdown), Task 12 (écriture fichiers). ✓
|
|
- **Docker + SU_MASTER_KEY + volumes** → Task 20. ✓
|
|
- **Tests vitest (parser, crypto, réducteur, template)** → Tasks 2,4,5,6,8,9,10,11 ; suite complète Task 19 step 6. ✓
|
|
- **API headless réutilisable** → Task 14/15 (toute la logique derrière `/api`, client sans logique métier). ✓
|
|
- **Réducteur déterministe APT** → Task 4, utilisé dans refresh (rawHints) et execute (importantLogLines). ✓
|
|
|
|
Aucun placeholder détecté. Noms de fonctions cohérents entre tâches (`refreshMachine`, `runAction`, `extractSection`, `reduceAptLines`, `parseAptSimulate`, `getCreds`, `outputHub`).
|