0fbca06d3d
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>
409 lines
16 KiB
Markdown
409 lines
16 KiB
Markdown
# 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.
|