docs: roadmap tâches 1.9-8 (briefs, gates de validation, designs tâche 2) + plans d'implémentation

Cartographie complète (liste_taches/coherence_taches), briefs tacheN + gates
validation_tacheN, design tâche 2 (docs/design/tache2/), specs/plans jalon 1-2
et tâche 1.9/2 (Phase 1, Phase 2, SJ-0→3). Validations consignées (1.9 , 2-8 🟡).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 19:50:25 +02:00
parent f9ce991ec5
commit 0fbca06d3d
39 changed files with 11916 additions and 12 deletions
+91
View File
@@ -0,0 +1,91 @@
# Tâche 2 — Moteur de templates de mise à jour : synthèse de design
> **Type** : document de design / spec. Aucune implémentation. Langue : français.
> **Périmètre** : design du moteur de templates (APT + Docker + scripts custom) et des contrats de données associés, prêt à passer en `writing-plans`.
> **Statut** : design figé proposé, à valider contre `validation_tache2.md` (gate obligatoire avant tout code).
---
## 1. Objet de la mission
Concevoir — sans coder — le **moteur de templates de mise à jour complet** et les **contrats JSON** associés, couvrant cinq axes :
- **Axe A** — Templates APT complets et OS-aware (update/analyse, upgrade, full/dist-upgrade, clean, autoremove, reboot-check, reboot vérifié), profils OS + type machine, proxy apt-cacher-ng.
- **Axe B** — Capture des mises à jour *prévues* (snapshot) et *appliquées* (diff réel avant/après), consommables par Hermes via déduplication + réduction déterministe.
- **Axe C** — Taxonomie des erreurs APT/dpkg/Docker + stratégie de remédiation.
- **Axe D** — Docker Compose : scan, inspect, pull-check, apply, prune, down, par SSH, avec racines déclarées + détection labels.
- **Axe E** — Scripts personnalisés (post-install, installation de paquets) avec garde-fous, manifestes et champs dynamiques.
La logique métier vit dans des **templates shell versionnés sur disque** (esprit `nas-ops`), rendus en Mustache et poussés en SSH (`server/ssh/client.ts`). Le backend orchestre, parse en **JSON canonique**, archive logs + rapports. Hermes analyse les JSON réduits, n'exécute jamais de SSH, ne reçoit jamais de secret.
---
## 2. Cartographie des livrables
| Fichier | Contenu | Axe / Livrable §4 |
|---|---|---|
| `00-synthese.md` (ce fichier) | Vue d'ensemble, décisions clés, couverture du gate | tous |
| `10-templates-apt.md` | Inventaire + pseudo-shell des templates APT, sémantique, marqueurs | A, §4.1, §4.2 |
| `20-docker.md` | Inventaire + pseudo-shell Docker Compose, flux, sécurité prune/down | D, §4.1, §4.2 |
| `30-scripts-custom.md` | Modèle des profils post-install, manifestes, champs dynamiques, garde-fous | E, §4.1, §4.7 |
| `40-contrats-json.md` | Schémas JSON canoniques étendus + types TS rétro-compatibles + déduplication/réduction | B, §4.3 |
| `50-erreurs.md` | Taxonomie des erreurs APT/dpkg/Docker/réseau + codes + remédiation | C, §4.4 |
| `60-profils-os-machine.md` | Modèle profils OS + type machine + overrides + proxy APT + détection | A, §4.5, §4.6 |
| `70-securite.md` | Frontière Hermes/MCP, actions destructives, validations, surface MCP | §4.8 |
| `80-sous-jalons.md` | Découpage en sous-jalons priorisé, prêt pour `writing-plans` | §4.9 |
| `90-questions-investigation.md` | Les 8 questions §3 tranchées (MVP / alternatives / risques) | §3 |
| `99-couverture-gate.md` | Auto-évaluation case par case de `validation_tache2.md` | gate |
---
## 3. Décisions structurantes (résumé)
Détail et justifications dans `90-questions-investigation.md`. Synthèse :
1. **Parsing : hybride, parsing-TS dominant (MVP).** On conserve l'approche actuelle (marqueurs `===SU:XXX===` + parsing TS dans `server/services/`), enrichie par des **données structurées en TSV/clé=valeur produites côté shell** (ex. `dpkg-query -W -f=...`, `docker ... --format json`) là où le format est déjà stable et documenté. Pas de génération de gros JSON imbriqué dans le shell. Rétro-compatible avec le jalon 1.
2. **Profils OS = fichiers de templates par profil + héritage par convention de dossier.** Arborescence `templates/<famille>/<commande>.sh.tpl` avec un profil `base` et des overrides par OS résolus par ordre de priorité. Le moteur de rendu choisit le template le plus spécifique disponible (fallback vers `base`).
3. **Type machine = choix manuel à l'ajout + action `machine_probe` de correction.** L'opérateur choisit `os_family` et `machine_kind` au formulaire ; une sonde non destructive propose des corrections (jamais appliquées automatiquement sans validation).
4. **Diff avant/après = snapshot dpkg autour de chaque action APT réelle.** `dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n'` avant et après ; le backend calcule le diff. L'exit code APT ne suffit jamais à déclarer un succès.
5. **Extensions de `shared/types.ts` : tout en champs optionnels.** Élargissement des unions (`OsFamily`, `ActionType`, `AptProxyMode`), ajout de blocs optionnels `apt` (détaillé), `docker`, `custom`/`postInstall`, `reboot`, `errors` sur `UpdateSnapshot` / `ExecutionResult`. Un snapshot/exécution du jalon 1 reste strictement valide.
6. **Opérations longues : `nohup` + fichier exit-code généralisé pour les actions applicatives longues, mais pas pour le refresh.** Reboot vérifié = mécanisme dédié (boot_id avant/après, reconnexion, délai adaptatif). Le refresh/analyse reste synchrone et court.
7. **Sécurité prune/down/scripts : barrière de validation côté webapp + `action_requests`.** Hermes propose, ne déclenche jamais. Secrets jamais lus ni renvoyés (registry creds, sudo, tokens). Erreurs nettoyées avant UI/MCP.
8. **Surface MCP minimale, en lecture + déclenchement d'actions déjà autorisées.** Réutilise les outils v1 du rapport (`list_machines`, `get_machine_snapshot`, `get_machine_execution`, `list_templates`, `preview_template`, `run_refresh`, `run_action`, `search_reports`) ; aucune nouvelle primitive d'exécution SSH exposée.
---
## 4. Principes invariants respectés
- **Convention templates** : tous les templates émettent des marqueurs `===SU:XXX===` ; `LC_ALL=C` ; `DEBIAN_FRONTEND=noninteractive` pour APT ; exécution sous `sudo -S` via `runScriptSudo` (base64) ; marqueur de sortie `===SU:EXIT=N===`.
- **Réduction déterministe avant LLM** : le réducteur (`aptReduce.ts`, à étendre en `reduceLines.ts`) ne garde que les lignes utiles APT et Docker ; le log brut complet est archivé séparément (`raw_artifacts` / `rawLogPath`).
- **Déduplication** : APT par `os_family + package + from + to + origin` ; Docker par `image + fromDigest + toDigest` (fallback `image + fromImageId + toImageId`).
- **Templates versionnés sur disque** : éditables depuis le front mais sauvegardés comme ressources de projet (revues Git), versionnés via `install_recipe_versions` pour les scripts custom.
- **Backend orchestre, shell porte la logique métier, JSON canonique = langage commun** frontend / MCP / Hermes.
- **Réutilisation de l'existant** : pas de nouveau mécanisme d'exécution SSH ; on s'appuie sur `runScriptSudo` / `runPlain` et sur la table `executions` + WebSocket terminal.
---
## 5. Alignement avec `tache1.9.md` (schéma BDD cible)
Les contrats JSON et tables dérivées de ce design se rangent dans les tables prévues :
- Snapshot APT/Docker → `snapshots(kind, payload_json, important_json)` + tables dérivées `apt_planned_packages`, `docker_compose_stacks`, `docker_stack_services`.
- Résultats d'exécution → `executions(result_json, important_json)` + `apt_applied_packages`, `docker_image_events`, `apt_errors`.
- Scripts custom → `install_profiles`, `install_recipes`, `install_recipe_versions`, `machine_profile_state`, `script_variables_presets`.
- Messages importants extraits → `important_messages`.
- Config Docker par machine → `docker_settings`, `docker_compose_roots`.
- Profils OS / type machine → colonnes `machines.os_family / machine_kind / virtualization / hardware_profile` + `machine_hardware` (sonde).
Aucune table nouvelle n'est requise par la tâche 2 ; le design réutilise la cible `tache1.9.md`.
---
## 6. Ce qui reste hors périmètre (suggestions, pas exécuté)
- Catalogue détaillé des scripts post-install → renvoyé à **tâche 4** (ce design pose le mécanisme moteur + les manifestes attendus).
- API/jobs/route d'action, file de jobs persistante → **tâche 5**.
- Affichage UI fin des snapshots/actions → **tâche 3**.
- Skill Hermes et analyse → **tâche 6**.
- Politique de rétention/purge des logs → **tâche 7**.
- Découverte réseau de machines → hors tâche 2.
Ces points sont mentionnés comme contexte d'emboîtement mais ne sont pas conçus en détail ici.
+202
View File
@@ -0,0 +1,202 @@
# 10 — Templates APT : inventaire, sémantique et pseudo-shell
> Axe A + livrables §4.1 et §4.2. Cohérent avec `templates/apt/check.sh.tpl`, `full-upgrade.sh.tpl`, `reboot.sh.tpl` existants, la convention `===SU:XXX===`, `LC_ALL=C`, `DEBIAN_FRONTEND=noninteractive`, exécution sous `sudo -S` (`server/ssh/client.ts`).
---
## 1. Sémantique APT clarifiée (manpage `apt-get`)
| Commande | Effet | Peut supprimer ? | Peut installer du nouveau ? |
|---|---|---|---|
| `apt-get update` | Resynchronise les index de paquets. Ne modifie aucun paquet installé. | non | non |
| `apt-get -s upgrade` | **Simulation** : installe les nouvelles versions des paquets installés **sans jamais supprimer** ni installer de nouveaux paquets. Les paquets dont l'upgrade exigerait une suppression/installation restent *held back*. | non | non |
| `apt-get -s dist-upgrade` / `full-upgrade` | **Simulation** : gère intelligemment les changements de dépendances, peut **installer de nouveaux paquets et en supprimer** pour satisfaire les dépendances. | oui | oui |
| `apt-get autoremove` | Retire les dépendances automatiquement installées et devenues inutiles. | oui | non |
| `apt-get clean` | Vide le cache local `/var/cache/apt/archives`. N'affecte pas l'état des paquets. | non | non |
> `apt full-upgrade` (commande `apt`) ≡ `apt-get dist-upgrade` (commande `apt-get`). **On utilise toujours `apt-get` en script** (non interactif, stable), jamais `apt` (UI humaine). L'UI parle d'« full-upgrade » comme alias convivial ; la commande système est `apt-get dist-upgrade`.
Lignes documentées parsées : `Inst <pkg> [<cur>] (<target> <origin> [<arch>])`, `Conf <pkg>`, `Remv <pkg>`. Le log brut complet reste archivé ; seules ces lignes + `E:`/`W:`/`dpkg:`/`reboot-required` alimentent Hermes.
Sources : `apt-get` https://manpages.debian.org/apt-get · `dpkg` https://manpages.debian.org/dpkg · `dpkg-query` https://manpages.debian.org/dpkg-query · `apt-listchanges` https://manpages.debian.org/bookworm/apt-listchanges/apt-listchanges.1.en.html · `needrestart` https://manpages.debian.org/bookworm/needrestart/needrestart.1.en.html
---
## 2. Inventaire des templates APT
| Template | Action (`ActionType`) | Rôle | Type | OS ciblés | Destructif ? | Marqueurs |
|---|---|---|---|---|---|---|
| `apt/update-analyze.sh.tpl` | `apt_update_analyze` | Refresh index + simulation `upgrade` et `dist-upgrade` + reboot-check. **Tâche de fond, non destructif.** Remplace/étend l'actuel `check.sh.tpl`. | snapshot | tous | non | `===SU:APT_UPDATE===`, `===SU:APT_SIM_UPGRADE===`, `===SU:APT_SIM_DISTUPGRADE===`, `===SU:APT_HELD===`, `===SU:REBOOT===`, `===SU:EXIT=N===` |
| `apt/upgrade.sh.tpl` | `apt_upgrade` | Applique l'upgrade simple (sans suppression volontaire). Snapshot dpkg avant/après. | action | tous | oui (modif paquets) | `===SU:DPKG_BEFORE===`, `===SU:APT_UPGRADE===`, `===SU:DPKG_AFTER===`, `===SU:REBOOT===`, `===SU:EXIT=N===` |
| `apt/full-upgrade.sh.tpl` | `apt_full_upgrade` | Applique `apt-get dist-upgrade`. **Conserve l'existant (jalon 1) en l'enrichissant du diff dpkg.** | action | tous | oui (peut supprimer) | idem upgrade + `===SU:APT_FULLUPGRADE===` |
| `apt/autoremove.sh.tpl` | `apt_autoremove` | Retire les dépendances inutiles, avec simulation préalable affichée. | action | tous | oui (supprime) | `===SU:APT_SIM_AUTOREMOVE===`, `===SU:DPKG_BEFORE===`, `===SU:APT_AUTOREMOVE===`, `===SU:DPKG_AFTER===`, `===SU:EXIT=N===` |
| `apt/clean.sh.tpl` | `apt_clean` | Vide le cache des paquets téléchargés. Action séparée, peu risquée. | action | tous | non (cache only) | `===SU:APT_CLEAN===`, `===SU:EXIT=N===` |
| `apt/reboot-check.sh.tpl` | (intégré au refresh) | Vérifie `/run/reboot-required`, `/var/run/reboot-required`, liste `reboot-required.pkgs`, état `needrestart -b`. | snapshot | tous | non | `===SU:REBOOT===` |
| `apt/reboot.sh.tpl` | `reboot_verified` | Lit `boot_id` avant, planifie le reboot, émet un marqueur parsable avant coupure SSH. Conserve l'existant en ajoutant `boot_id`. | action | tous | oui (reboot) | `===SU:BOOT_ID_BEFORE===`, `===SU:REBOOT_NOW===` |
> **Compatibilité jalon 1** : `apt_full_upgrade` et `reboot` restent des actions valides. `check.sh.tpl` n'est pas supprimé ; `update-analyze.sh.tpl` est son successeur enrichi (le refresh peut basculer dessus sans casser le parsing existant — voir §6 ci-dessous).
---
## 3. Variables de rendu (Mustache)
Étend `TemplateVars` (extension proposée, voir `40-contrats-json.md`). Variables utilisées par les templates APT :
```text
aptProxy string|null proxy apt-cacher-ng injecté à l'exécution (mode runtime)
osProfile string debian | ubuntu | proxmox | raspbian
machineKind string physical | vm | proxmox_host | lxc | raspberry_pi | workstation
confValues bool true => --force-confdef --force-confold (défaut)
inactivityTimeout int secondes; 0 = désactivé (défaut 600)
```
Les sections Mustache `{{#aptProxy}}…{{/aptProxy}}` restent identiques à l'existant.
---
## 4. Pseudo-shell des templates clés
### 4.1 `apt/update-analyze.sh.tpl` (refresh + analyse, non destructif)
```sh
#!/bin/sh
# Refresh index + simulations upgrade/dist-upgrade + reboot-check.
# Exécuté entier sous sudo par la couche SSH. Non destructif.
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:APT_UPDATE==="
apt-get update -qq 2>&1
UPD=$?
echo "===SU:APT_SIM_UPGRADE==="
apt-get -s -y upgrade 2>&1
echo "===SU:APT_SIM_DISTUPGRADE==="
apt-get -s -y dist-upgrade 2>&1
echo "===SU:APT_HELD==="
# Paquets retenus (held back) : présents en dist-upgrade mais pas en upgrade.
apt-mark showhold 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then
echo "REBOOT_REQUIRED=1"
[ -f /var/run/reboot-required.pkgs ] && sed 's/^/PKG=/' /var/run/reboot-required.pkgs
else
echo "REBOOT_REQUIRED=0"
fi
# needrestart en mode batch/list si présent (jamais interactif).
command -v needrestart >/dev/null 2>&1 && needrestart -b 2>/dev/null | grep -E '^NEEDRESTART-(KSTA|SVC)' || true
echo "===SU:EXIT=${UPD}==="
```
Le backend parse :
- section `APT_SIM_UPGRADE` → liste `upgrade` (paquets `Inst`),
- section `APT_SIM_DISTUPGRADE` → liste `dist-upgrade` (inclut `Inst` nouveaux + `Remv` suppressions),
- `held` = `APT_HELD` (et/ou paquets présents en dist-upgrade absents d'upgrade),
- `REBOOT_REQUIRED` + `PKG=` → reboot requis + paquets concernés.
Statut snapshot APT : `ok` si rien ; `updates_available` si `Inst` non vides ; `warning` si dist-upgrade implique des `Remv` ou des `held` ; `error` si `APT_UPDATE` échoue (dépôt injoignable, clé GPG…).
### 4.2 `apt/full-upgrade.sh.tpl` (application, avec diff dpkg)
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_FULLUPGRADE==="
apt-get -y \
-o Dpkg::Options::=--force-confdef \
-o Dpkg::Options::=--force-confold \
dist-upgrade 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
```
Le backend compare `DPKG_BEFORE` et `DPKG_AFTER` (clé = `package+arch`) → `installed` / `upgraded` / `removed` / `unchanged`, versions finales réelles. **L'exit code seul ne suffit pas** : un paquet annoncé en simulation mais resté inchangé est signalé comme anomalie.
> Politique non interactive justifiée : `--force-confdef`+`--force-confold` conservent les fichiers de config locaux quand dpkg ne peut pas trancher, pour ne pas écraser une configuration distante. Les prompts (conffile, debconf, apt-listchanges, needrestart) sont traités comme **risques de blocage à détecter** (timeout d'inactivité), pas comme dialogues à exposer. Voir `50-erreurs.md` (`human_interaction_required`).
### 4.3 `apt/autoremove.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:APT_SIM_AUTOREMOVE==="
apt-get -s -y autoremove 2>&1 # prévisualisation des Remv
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_AUTOREMOVE==="
apt-get -y autoremove 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:EXIT=${CODE}==="
```
Confirmation UI explicite requise (action qui supprime des paquets).
### 4.4 `apt/clean.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:APT_CLEAN==="
BEFORE=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
apt-get clean 2>&1
AFTER=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
echo "FREED_BYTES=$((BEFORE - AFTER))"
echo "===SU:EXIT=0==="
```
### 4.5 `apt/reboot.sh.tpl` (reboot vérifié — capture boot_id)
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:BOOT_ID_BEFORE==="
cat /proc/sys/kernel/random/boot_id 2>/dev/null
echo "===SU:REBOOT_NOW==="
# Reboot différé pour laisser le canal SSH se fermer proprement.
nohup sh -c 'sleep 2; reboot' >/dev/null 2>&1 &
echo "reboot planifié"
```
Le **backend** orchestre la vérification (hors template) : il a lu `boot_id` *avant*, attend la coupure SSH, retente la connexion (délai adaptatif), relit `boot_id` via `runPlain` (`cat /proc/sys/kernel/random/boot_id`). Reboot `ok` seulement si la machine revient ET `boot_id` a changé. Statuts d'échec : `reboot_command_failed`, `ssh_never_went_down`, `machine_did_not_return`, `boot_id_unchanged`, `timeout`. Champs résultat : `beforeBootId`, `afterBootId`, `requestedAt`, `sshWentDownAt`, `sshCameBackAt`, `waitedSeconds`, `status`, `errors`. Délai adaptatif par machine : `lastRebootDurationSeconds``nextRecommendedWaitSeconds` (avec marge). Voir `40-contrats-json.md` (`RebootResult`).
---
## 5. Spécificités par profil OS (détail dans `60-profils-os-machine.md`)
- **Debian** : avant de proposer firmware/drivers propriétaires, vérifier la présence des composants `contrib`, `non-free`, `non-free-firmware` (template `apt/check-components.sh.tpl`, lecture seule). Pas d'activation automatique.
- **Ubuntu** : `ubuntu-drivers devices` (lecture) pour proposer des drivers (NVIDIA/GPU) — proposition uniquement, jamais installé par défaut.
- **Proxmox** : profil dédié. Vérifier dépôts PVE (`pve-no-subscription` vs `enterprise`), meta-package `proxmox-ve`, kernel PVE, Ceph éventuel, puis `apt-get dist-upgrade`. Ne **jamais** traiter comme une Debian générique.
- **Raspberry Pi OS** : profil dédié. Attention firmware/kernel, **vérifier l'espace disque avant upgrade**, utiliser `full-upgrade`.
Le template le plus spécifique disponible sous `templates/<profil>/<commande>.sh.tpl` est choisi, sinon fallback `templates/apt/<commande>.sh.tpl` (profil `base`).
---
## 6. Compatibilité et migration (non-régression jalon 1)
- L'actuel `check.sh.tpl` produit `===SU:UPDATE===` / `===SU:SIMULATE===` / `===SU:REBOOT===` / `===SU:END===`. Le nouveau `update-analyze.sh.tpl` ajoute des sections **sans supprimer** la sémantique : le parsing actuel (`extractSection(raw, "===SU:SIMULATE===", "===SU:REBOOT===")`) peut être conservé en parallèle pendant la migration.
- **Recommandation MVP** : introduire `update-analyze.sh.tpl` comme nouveau template et faire pointer le refresh dessus dans un sous-jalon dédié, en gardant `check.sh.tpl` jusqu'à bascule validée. Aucune rupture du flux prouvé en prod.
- `full-upgrade.sh.tpl` et `reboot.sh.tpl` existants restent fonctionnels ; on **ajoute** les sections `DPKG_BEFORE/AFTER` et `BOOT_ID_BEFORE` (extension, pas remplacement de marqueurs).
+211
View File
@@ -0,0 +1,211 @@
# 20 — Docker Compose : inventaire, flux et pseudo-shell
> Axe D + livrables §4.1/§4.2. Conçu pour cocher chaque case de `validation_tache2.md §6` (« Focus Docker Compose »). Gestion **par SSH sur la machine cible** via `server/ssh/client.ts` et templates versionnés ; pas de moteur parallèle.
---
## 1. Méthode retenue (MVP)
- **Gestion par SSH** sur la machine cible, réutilisant `runScriptSudo` / `runPlain` et la table `executions`, le WebSocket terminal, `rawLogPath`, `reportPath`, statut `ok|warning|error`. **Pas de second système de jobs Docker.**
- Variante `docker context` over SSH : **citée comme alternative opérateur**, pas le moteur MVP.
- **Découverte des stacks** depuis des **racines déclarées par machine** (`composeRoots`, ex. `/opt/stacks`, `/srv/docker`), scan limité en profondeur (`composeScanDepth`, défaut 4), puis validation UI.
- **Détection par labels Compose** (`com.docker.compose.project`, `com.docker.compose.service`, et si présent `com.docker.compose.project.working_dir`) = **complément** pour retrouver les stacks actifs, pas l'unique source de vérité.
- Cycle de vie d'un stack : `candidate` (juste détecté) → `enabled` (validé par l'utilisateur) → actions autorisées. `pull`, `up`, `down`, `prune` **uniquement sur un stack `enabled`/validé**.
Sources citées : `docker compose pull` https://docs.docker.com/reference/cli/docker/compose/pull/ · `up` https://docs.docker.com/reference/cli/docker/compose/up/ · `config` https://docs.docker.com/reference/cli/docker/compose/config/ · `ps` https://docs.docker.com/reference/cli/docker/compose/ps/ · `images` https://docs.docker.com/reference/cli/docker/compose/images/ · `down` https://docs.docker.com/reference/cli/docker/compose/down/ · `image inspect` https://docs.docker.com/reference/cli/docker/image/inspect/ · `image prune` https://docs.docker.com/reference/cli/docker/image/prune/
---
## 2. Inventaire des templates Docker
| Template | Action (`ActionType`) | Rôle | Effet disque | Destructif ? | Validation UI |
|---|---|---|---|---|---|
| `docker/scan-compose.sh.tpl` | `docker_scan` | Scanne les racines déclarées, trouve les fichiers compose, valide chaque candidat. **Passif.** | non | non | non |
| `docker/inspect-compose.sh.tpl` | `docker_inspect_current` | État actuel sans changement : config images, ps, images, inspect. **Passif.** | non | non | non |
| `docker/pull-check.sh.tpl` | `docker_pull_check` | `docker compose pull` (télécharge sans démarrer), compare ID/digest/labels avant-après. **Écrit sur le disque Docker** (pas un scan pur). | oui (cache images) | non (pas applicatif) | non (mais non passif) |
| `docker/apply-compose.sh.tpl` | `docker_compose_apply` | `docker compose up -d --remove-orphans` après validation. Recapture ps/images/inspect. | oui | oui (recrée conteneurs) | **oui, explicite** |
| `docker/prune-images.sh.tpl` | `docker_prune_images` | `docker image prune -f` (safe) ; mode agressif `-a -f --filter "until=168h"`. | oui | oui (agressif) | **oui pour agressif** |
| `docker/down-compose.sh.tpl` | `docker_compose_down` | `docker compose down`. **Action séparée et destructive**, hors chemin de mise à jour normal. | oui | oui | **oui, forte** |
> `--volumes` et `--rmi` sur `down` : **interdits au MVP** (ou protégés par une validation forte distincte). Le chemin de mise à jour normal n'utilise jamais `down` : `up -d` recrée les conteneurs quand l'image ou la config change, en préservant les volumes montés.
Tous les templates : `LC_ALL=C`, marqueurs `===SU:DOCKER_*===`, sortie parsable, log brut archivé.
---
## 3. Flux de mise à jour Docker (formalisé)
1. `docker_scan` — découverte des stacks candidats (racines déclarées + labels actifs).
2. `docker_inspect_current` — état actuel des services, conteneurs, images.
3. `docker_pull_check` — téléchargement des images candidates **sans démarrage de conteneurs**.
4. Comparaison déterministe — image ref, image ID, repo digest, labels OCI (`org.opencontainers.image.version`, `revision`, `source`, `created`) si présents.
5. Proposition UI/Hermes — liste des stacks/services avec update dispo, erreurs de pull, inconnues.
6. `docker_compose_apply` après validation utilisateur — `docker compose up -d --remove-orphans`.
7. Vérification après application — conteneurs recréés, état `running/exited`, health si dispo, erreurs.
8. `docker_prune_images` après succès ou action séparée — images supprimées, espace récupéré, erreurs.
**Points clés validés** :
- `docker compose pull` télécharge mais **ne démarre pas** les conteneurs → bon pré-check applicatif, pas un scan sans effet.
- `docker compose up -d` recrée les conteneurs quand l'image/config a changé, **en préservant les volumes**`down` inutile pour une MAJ normale.
- `docker image prune -f` = images *dangling* (sûr) ; `docker image prune -a` = **toutes** les images non référencées par un conteneur (destructif).
---
## 4. Pseudo-shell des templates
### 4.1 `docker/scan-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:DOCKER_SCAN==="
# {{composeRoots}} rendu en liste shell-safe par le backend (une racine par ligne).
ROOTS="{{composeRoots}}"
DEPTH="{{composeScanDepth}}"
for root in $ROOTS; do
[ -d "$root" ] || continue
find "$root" -maxdepth "$DEPTH" -type f \
\( -name 'compose.yaml' -o -name 'compose.yml' \
-o -name 'docker-compose.yaml' -o -name 'docker-compose.yml' \) \
-not -path '*/.git/*' -not -path '*/node_modules/*' \
-not -path '*/backup/*' -not -path '*/old/*' -not -path '*/archive/*' \
2>/dev/null | while IFS= read -r f; do
dir=$(dirname "$f")
# Valide le candidat ; n'applique rien.
if docker compose -f "$f" config --quiet >/dev/null 2>&1; then
echo "STACK_OK\tdir=$dir\tfile=$f"
else
echo "STACK_INVALID\tdir=$dir\tfile=$f"
fi
done
done
echo "===SU:DOCKER_LABELS==="
# Complément : stacks actifs détectés par labels.
docker ps --format '{{ "{{.ID}}" }}' 2>/dev/null | while read -r id; do
proj=$(docker inspect --format '{{ "{{index .Config.Labels \"com.docker.compose.project\"}}" }}' "$id" 2>/dev/null)
wd=$(docker inspect --format '{{ "{{index .Config.Labels \"com.docker.compose.project.working_dir\"}}" }}' "$id" 2>/dev/null)
[ -n "$proj" ] && echo "ACTIVE\tproject=$proj\tworking_dir=$wd"
done
echo "===SU:EXIT=0==="
```
> Note de rendu : les `{{ }}` Docker Go-template sont échappés ici pour ne pas être interprétés par Mustache (le moteur de rendu réel utilisera des délimiteurs Mustache personnalisés ou un échappement, à fixer en implémentation). Seules `composeRoots`/`composeScanDepth` sont des variables Mustache.
### 4.2 `docker/inspect-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { echo "===SU:DOCKER_ERR===\ncompose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
echo "===SU:DOCKER_CONFIG_IMAGES==="
docker compose config --images 2>&1
echo "===SU:DOCKER_PS==="
docker compose ps --format json 2>&1
echo "===SU:DOCKER_IMAGES==="
docker compose images --format json 2>&1
echo "===SU:DOCKER_INSPECT==="
# Pour chaque image utilisée : Id, RepoDigests, labels OCI.
docker compose config --images 2>/dev/null | while IFS= read -r img; do
docker image inspect "$img" \
--format '{{ "IMG\t{{.Id}}\t{{join .RepoDigests \",\"}}\t{{index .Config.Labels \"org.opencontainers.image.version\"}}\t{{index .Config.Labels \"org.opencontainers.image.source\"}}" }}' 2>/dev/null \
|| echo "IMG_MISSING\t$img"
done
echo "===SU:EXIT=0==="
```
### 4.3 `docker/pull-check.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
echo "===SU:DOCKER_INSPECT_BEFORE==="
docker compose config --images 2>/dev/null | while IFS= read -r img; do
id=$(docker image inspect "$img" --format '{{ "{{.Id}}" }}' 2>/dev/null || echo "")
dg=$(docker image inspect "$img" --format '{{ "{{join .RepoDigests \",\"}}" }}' 2>/dev/null || echo "")
echo "BEFORE\t$img\t$id\t$dg"
done
echo "===SU:DOCKER_PULL==="
# Télécharge les images candidates SANS démarrer de conteneurs.
docker compose pull --policy always --ignore-buildable 2>&1
CODE=$?
echo "===SU:DOCKER_INSPECT_AFTER==="
docker compose config --images 2>/dev/null | while IFS= read -r img; do
id=$(docker image inspect "$img" --format '{{ "{{.Id}}" }}' 2>/dev/null || echo "")
dg=$(docker image inspect "$img" --format '{{ "{{join .RepoDigests \",\"}}" }}' 2>/dev/null || echo "")
ver=$(docker image inspect "$img" --format '{{ "{{index .Config.Labels \"org.opencontainers.image.version\"}}" }}' 2>/dev/null || echo "")
echo "AFTER\t$img\t$id\t$dg\t$ver"
done
echo "===SU:EXIT=${CODE}==="
```
Backend : compare `BEFORE`/`AFTER` par `image ref`. Si `id`/`digest` change → `updates_available`. Erreurs `pull_failed`/`registry_auth_failed` nettoyées (jamais d'URL/token sensible vers UI/MCP).
### 4.4 `docker/apply-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
echo "===SU:DOCKER_APPLY==="
docker compose up -d --remove-orphans 2>&1
CODE=$?
echo "===SU:DOCKER_PS_AFTER==="
docker compose ps --format json 2>&1
echo "===SU:DOCKER_INSPECT_AFTER==="
docker compose config --images 2>/dev/null | while IFS= read -r img; do
docker image inspect "$img" --format '{{ "IMG\t{{.Id}}\t{{join .RepoDigests \",\"}}" }}' 2>/dev/null || echo "IMG_MISSING\t$img"
done
echo "===SU:EXIT=${CODE}==="
```
### 4.5 `docker/prune-images.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:DOCKER_PRUNE==="
{{#aggressive}}
# Mode agressif : nécessite validation UI explicite distincte.
docker image prune -a -f --filter "until=168h" 2>&1
{{/aggressive}}
{{^aggressive}}
# Mode sûr par défaut : dangling images uniquement.
docker image prune -f 2>&1
{{/aggressive}}
CODE=$?
echo "===SU:EXIT=${CODE}==="
```
Le backend parse `Total reclaimed space` et `deleted` pour `bytesReclaimed` et la liste d'images supprimées.
### 4.6 `docker/down-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
echo "===SU:DOCKER_DOWN==="
# --volumes et --rmi INTERDITS au MVP. down simple uniquement.
docker compose down 2>&1
CODE=$?
echo "===SU:EXIT=${CODE}==="
```
---
## 5. Réduction Hermes (Docker)
Seules ces lignes (+ le JSON canonique) sont transmises : `Pulling`, `Digest`, `Status`, `Downloaded newer image`, `Recreating`, `Started`, `Error`, `deleted`, `Total reclaimed space`. Le log brut complet reste archivé (`raw_artifacts` / `rawLogPath`).
---
## 6. Insertion dans la webapp existante
- **Config machine** : nouveaux champs `dockerEnabled`, `composeRoots[]`, `composeScanDepth`, `composeStacks[]`**optionnels** ou dans un endpoint dédié ; `MachineView` n'est pas cassé (les champs sont ajoutés en option). Stockés dans `docker_settings` / `docker_compose_roots` / `docker_compose_stacks` (`tache1.9.md`).
- **Refresh/snapshot** : le refresh machine peut produire un snapshot combiné `apt` + `docker`, **ou** un refresh Docker séparé pour éviter de lancer `docker_pull_check` automatiquement (recommandé : pull-check séparé car non-passif).
- **Actions** : extension progressive de `ActionType` (`docker_scan`, `docker_pull_check`, `docker_compose_apply`, `docker_prune_images`, `docker_compose_down`) avec filtrage d'autorisation conservé sur `POST /api/machines/:id/actions`.
- **Executions/rapports** : réutilisation de la table `executions`, du WebSocket terminal, de `rawLogPath`/`reportPath`/statut. Pas de second système.
- **UI machine** : compteur Docker séparé du compteur APT (ex. stacks avec updates) ; vue détail par stack/service ; boutons d'action validés (`Pull/check`, `Appliquer`, `Prune`, `Down`).
- **Validation utilisateur** : `docker_compose_apply`, `docker_prune_images` agressif et `docker_compose_down` passent par une confirmation UI explicite (via `action_requests`). Hermes propose, ne déclenche jamais.
- **Secrets** : credentials registry (`~/.docker/config.json`, helpers, tokens) **jamais lus ni renvoyés** ; erreurs nettoyées si elles exposent une URL sensible (voir `70-securite.md`).
+171
View File
@@ -0,0 +1,171 @@
# 30 — Scripts personnalisés / post-install : modèle moteur
> Axe E + livrables §4.1/§4.7. Conçu pour cocher `validation_tache2.md §8` (« scripts post-install et profils personnalisés »). **Le catalogue détaillé est renvoyé à la tâche 4** ; ce document pose le **mécanisme moteur**, les **manifestes**, les **champs dynamiques** et les **garde-fous**.
---
## 1. Principe UX et absence d'interactivité SSH
- **Interdiction stricte des questions interactives au milieu d'un script SSH.** Toute question nécessaire devient un **champ de formulaire** dans la webapp avant exécution.
- Les profils post-install sont **cochables** ; cocher un profil déplie ses champs obligatoires.
- Chaque profil fournit un **manifeste** : `id`, `label`, `description`, `fields`, valeurs par défaut, validations, prévisualisation, `risk`, `requiresConfirmation`.
- Le bouton d'exécution reste **désactivé** tant que les champs requis des profils cochés ne sont pas valides.
- La webapp propose une **preview du template rendu** avant exécution (`preview_template`), avec **masquage des secrets** et signalement des changements réseau/reboot.
- Les scripts s'exécutent en **mode non interactif** ; s'ils détectent une décision non fournie, ils **échouent avec une erreur structurée** au lieu de bloquer.
Stockage : `install_profiles` (catalogue + `manifest_json`), `install_recipes`/`install_recipe_versions` (scripts versionnés + sha256), `machine_profile_state` (état par machine, variables **non sensibles**), `script_variables_presets` (préréglages réutilisables). Cf. `tache1.9.md §9`.
---
## 2. Profils post-install attendus (composables)
| Profil | Rôle | Risque | Confirmation |
|---|---|---|---|
| `bootstrap_root` | Première prépa après DHCP/`su -` : installe `sudo`, `resolvconf`, `ca-certificates`, `curl` ; ajoute l'opérateur au groupe `sudo` ; vérifie `sudo`. | low | non |
| `identity_network` | Hostname, domaine/search `.home`, `/etc/hosts`, IP statique dans `/etc/network/interfaces`. | network_change | **oui** |
| `base_tools` | Outils de base **sans vim** : `nano`, `less`, `bash-completion`, `tmux`, `screen`, `htop`, `iotop`, `ncdu`, `tree`, `rsync`, `unzip`, `zip`, `tar`. | low | non |
| `network_tools` | `iproute2`, `iputils-ping`, `dnsutils`, `traceroute`, `net-tools`(opt), `tcpdump`, `nmap`, `mtr-tiny`, `lsof`, `netcat-openbsd`. | low | non |
| `dev_git` | `git`, `curl`, `wget`, `jq`, `yq`, `gnupg`, `lsb-release` ; `build-essential` optionnel. | low | non |
| `sharing` | `samba`, `nfs-kernel-server`, `avahi-daemon`, `libnss-mdns`, configurables séparément. | medium | oui |
| `docker_official` | Docker Engine depuis le dépôt officiel Debian : `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin` ; ajout user au groupe `docker` ; dossier Compose dans le home ; reboot/reconnexion si nécessaire. | medium | oui |
| `vm_guest_tools` | `qemu-guest-agent` ou `open-vm-tools` selon hyperviseur choisi. | low | non |
| Optionnels (non installés par défaut) | `security_basic`, `backup_tools`, `monitoring`, `mail_notify`, `time_sync`, `storage_tools`. | variable | selon profil |
> Les scripts hardware/drivers/benchmark ne sont **jamais installés par défaut** et exigent validation (voir `60-profils-os-machine.md`).
---
## 3. Champs dynamiques générés
- **`identity_network`** : `newHostname`, `domain`/`search`, `interfaceName`, `staticAddress` (CIDR, ex. `10.0.x.y/22`), `gateway` (défaut `10.0.0.1`), `dnsNameservers` (défaut `10.0.0.1`, `10.0.0.10`), `reconnectHost`.
- **`docker_official`** : `dockerUser` (ex. `gilles`), `dockerHomeDir`/`composeRoot` (ex. `/home/gilles/docker`), `installComposePlugin`, `rebootAfterInstall`.
- **`sharing`** : choix séparé Samba/NFS/mDNS, noms de partages, chemins autorisés, utilisateurs/groupes si nécessaire.
- **`vm_guest_tools`** : type d'hyperviseur / paquet cible.
Les champs peuvent être **préremplis** depuis la machine (`machine.name`, IP DHCP, interface primaire détectée par `machine_probe`, utilisateur SSH), mais restent **modifiables** avant validation.
### Exemple de manifeste (attendu dans la spec)
```json
{
"id": "identity_network",
"label": "Hostname + IP statique",
"requiresConfirmation": true,
"risk": "network_change",
"fields": [
{ "name": "newHostname", "type": "hostname", "required": true },
{ "name": "domain", "type": "string", "required": true, "default": "home" },
{ "name": "interfaceName", "type": "select", "required": true, "defaultFrom": "detected.primaryInterface" },
{ "name": "staticAddress", "type": "ipv4_cidr", "required": true },
{ "name": "gateway", "type": "ipv4", "required": true, "default": "10.0.0.1" },
{ "name": "dnsNameservers", "type": "ipv4_list", "required": true, "default": ["10.0.0.1", "10.0.0.10"] },
{ "name": "reconnectHost", "type": "ipv4", "required": true, "defaultFrom": "staticAddress.ip" }
]
}
```
Types de champ proposés : `string`, `hostname`, `ipv4`, `ipv4_cidr`, `ipv4_list`, `select`, `bool`, `int`, `path`, `secret` (jamais sérialisé en clair, jamais envoyé à Hermes/MCP). `defaultFrom` référence une valeur détectée par `machine_probe`.
---
## 4. Templates custom attendus (pseudo-shell)
Tous : `LC_ALL=C`, `DEBIAN_FRONTEND=noninteractive`, marqueurs `===SU:CUSTOM_*===`, `===SU:EXIT=N===`, sortie parsable, log brut archivé. Échec contrôlé si décision manquante.
### 4.1 `custom/bootstrap-root.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_BOOTSTRAP==="
apt-get update -qq 2>&1
apt-get install -y sudo resolvconf ca-certificates curl 2>&1
CODE=$?
# Ajoute l'opérateur au groupe sudo (variable de formulaire, non secret).
usermod -aG sudo "{{operatorUser}}" 2>&1 || echo "WARN usermod"
# Vérifie sudo
su - "{{operatorUser}}" -c 'sudo -n true' 2>&1 && echo "SUDO_OK" || echo "SUDO_CHECK_PENDING"
echo "===SU:EXIT=${CODE}==="
```
### 4.2 `custom/identity-network.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:CUSTOM_IDENTITY==="
# Sauvegardes avant modification.
cp -a /etc/hosts "/etc/hosts.su.bak.$(date +%s)" 2>/dev/null
cp -a /etc/network/interfaces "/etc/network/interfaces.su.bak.$(date +%s)" 2>/dev/null
OLD_IP="{{dhcpEndpoint}}"
echo "OLD_ENDPOINT=${OLD_IP}"
hostnamectl set-hostname "{{newHostname}}" 2>&1 || echo "hostname_failed"
# Réécrit /etc/network/interfaces pour {{interfaceName}} en statique {{staticAddress}}.
# (rendu détaillé en tâche 4 ; échoue proprement si interface absente)
echo "NEW_ENDPOINT={{reconnectHost}}"
echo "RECONNECT_REQUIRED=1"
echo "===SU:EXIT=0==="
```
Le script **ne coupe jamais la connexion sans stratégie de reconnexion planifiée par la webapp**. Si reboot requis → mécanisme `reboot_verified`. Après application, la webapp vérifie la reconnexion sur la nouvelle IP/hostname et met à jour la machine si le retour est confirmé. Erreurs distinguées : `network_config_invalid`, `interface_not_found`, `dns_config_failed`, `reconnect_failed`, `hostname_failed`, `sudo_setup_failed`.
### 4.3 `custom/install-package-groups.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_PKGGROUPS==="
# {{packages}} rendu comme liste shell-safe par le backend (jamais de vim par défaut).
apt-get update -qq 2>&1
apt-get install -y {{packages}} 2>&1
CODE=$?
echo "===SU:EXIT=${CODE}==="
```
### 4.4 `custom/docker-official-debian.sh.tpl`
Suit la doc officielle Docker Debian (https://docs.docker.com/engine/install/debian/, https://docs.docker.com/compose/install/linux/) : clé GPG dans `/etc/apt/keyrings`, `docker.sources`, puis paquets.
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_DOCKER==="
apt-get update -qq 2>&1
apt-get install -y ca-certificates curl 2>&1
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc 2>&1
chmod a+r /etc/apt/keyrings/docker.asc
# docker.sources écrit selon codename détecté (non secret).
apt-get update -qq 2>&1
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 2>&1
CODE=$?
usermod -aG docker "{{dockerUser}}" 2>&1 || echo "WARN docker group"
mkdir -p "{{composeRoot}}" 2>&1
echo "DOCKER_GROUP_RELOGIN_REQUIRED=1"
{{#rebootAfterInstall}}echo "REBOOT_REQUESTED=1"{{/rebootAfterInstall}}
echo "===SU:EXIT=${CODE}==="
```
### 4.5 `custom/sharing.sh.tpl` et `4.6 custom/vm-guest-tools.sh.tpl`
Installent Samba/NFS/mDNS selon les choix (sans config dangereuse par défaut) / l'agent invité choisi. Mêmes conventions de marqueurs et d'échec contrôlé. Détail des configs renvoyé à la tâche 4.
Sources citées : Docker Debian https://docs.docker.com/engine/install/debian/ · Compose plugin https://docs.docker.com/compose/install/linux/ · Debian network https://wiki.debian.org/NetworkConfiguration · Debian Handbook https://www.debian.org/doc/manuals/debian-handbook/sect.network-config · resolvconf https://packages.debian.org/stable/net/resolvconf
---
## 5. JSON canonique post-install
`ExecutionResult` reçoit un bloc optionnel `postInstall` (voir `40-contrats-json.md`) listant : profils exécutés, variables **non sensibles** utilisées, fichiers modifiés, paquets installés, services activés/démarrés, reboots demandés, erreurs. Secrets/tokens **jamais** inclus (variables sérialisées, logs UI, rapports, MCP). Les changements réseau/Docker sont marqués dans le rapport Markdown avec les prochaines actions attendues (reconnexion, logout/login groupe Docker, reboot).
---
## 6. Insertion dans la webapp existante
- Même mécanique que les autres actions : templates versionnés, preview, exécution SSH (`runScriptSudo`), WebSocket terminal, `executions`, rapport Markdown, log brut.
- Valeurs réutilisables conservées dans `script_variables_presets` (scope `global`/`machine`/`profile`) ; état par machine dans `machine_profile_state`. Le provisioning peut être un assistant ponctuel ou stocké par machine.
- Hermes peut proposer des profils ou expliquer un échec, mais ne reçoit que le JSON réduit et **ne déclenche jamais** les actions à risque sans validation webapp.
- Découpage en sous-jalons indépendants : bootstrap/sudo, identité+réseau, paquets de base, Docker officiel, partage réseau, outils VM/monitoring (voir `80-sous-jalons.md`).
+311
View File
@@ -0,0 +1,311 @@
# 40 — Contrats JSON canoniques étendus + types TypeScript
> Axe B + livrable §4.3. Tranche la question §3.5 (extensions de `shared/types.ts`). **Tous les ajouts sont rétro-compatibles** : champs optionnels, unions élargies. Un `UpdateSnapshot`/`ExecutionResult` du jalon 1 reste strictement valide.
---
## 1. Principe de rétro-compatibilité
État actuel (`shared/types.ts`) :
```ts
export type OsFamily = "debian" | "ubuntu" | "unknown";
export type AptProxyMode = "direct" | "runtime";
export type ActionType = "apt_full_upgrade" | "reboot";
// UpdateSnapshot.apt: { enabled, count, rebootRequired, packages: AptPackage[] }
// ExecutionResult: { ... action: ActionType, status, rebootRequiredAfterRun, importantLogLines, rawLogRef, reportRef }
```
Règles d'extension :
1. **Élargir les unions** (`OsFamily`, `AptProxyMode`, `ActionType`) — additif, aucun retrait.
2. **Ajouter des blocs optionnels** (`docker?`, `errors?`, `apt` détaillé optionnel, `reboot?`, `postInstall?`) — un payload sans ces blocs reste valide.
3. **Ne jamais retirer ni renommer** un champ existant. Le jalon 1 émet `apt: { enabled, count, rebootRequired, packages }` ; on **ajoute** des champs optionnels à côté.
4. Versionner via `schemaVersion?: number` (aligné `snapshots.schema_version` / `executions.schema_version` de `tache1.9.md`). Absence ⇒ version 1.
---
## 2. Extensions des unions
```ts
// Élargissement additif. Le jalon 1 ("debian"|"ubuntu"|"unknown") reste valide.
export type OsFamily = "debian" | "ubuntu" | "proxmox" | "raspbian" | "unknown";
export type MachineKind =
| "physical" | "vm" | "proxmox_host" | "lxc"
| "raspberry_pi" | "workstation" | "unknown";
// "persistent" ajouté (écriture dans /etc/apt/apt.conf.d/).
export type AptProxyMode = "direct" | "runtime" | "persistent";
export type ActionType =
// jalon 1 (conservés tels quels)
| "apt_full_upgrade" | "reboot"
// APT
| "apt_update_analyze" | "apt_upgrade" | "apt_dist_upgrade"
| "apt_autoremove" | "apt_clean" | "reboot_verified"
// Docker
| "docker_scan" | "docker_inspect_current" | "docker_pull_check"
| "docker_compose_apply" | "docker_prune_images" | "docker_compose_down"
// probe + custom
| "machine_probe" | "post_install";
export type SnapshotStatus = "ok" | "updates_available" | "warning" | "error";
export type ExecutionStatus = "ok" | "warning" | "error"; // inchangé
```
> `reboot` (jalon 1) et `reboot_verified` coexistent : `reboot_verified` ajoute la vérification boot_id ; le code jalon 1 continue d'émettre `reboot`.
---
## 3. Snapshot canonique étendu (`UpdateSnapshot`)
```ts
export interface AptPackage {
name: string;
currentVersion: string | null;
targetVersion: string;
origin: string | null;
// Ajouts optionnels (rétro-compatibles) :
arch?: string;
operation?: "upgrade" | "install" | "remove" | "hold";
severityHint?: "normal" | "security";
}
export interface AptSnapshotDetail {
enabled: boolean;
count: number;
rebootRequired: boolean;
packages: AptPackage[];
// Ajouts optionnels :
status?: SnapshotStatus; // ok | updates_available | warning | error
upgradeCount?: number; // simulation `upgrade`
distUpgradeCount?: number; // simulation `dist-upgrade`
installed?: AptPackage[]; // nouveaux paquets (dist-upgrade)
removed?: AptPackage[]; // suppressions prévues (=> status warning)
held?: string[]; // paquets retenus (=> status warning)
rebootPkgs?: string[]; // depuis reboot-required.pkgs
}
export interface DockerSnapshotService {
serviceName: string;
image: string; // image ref (ex. jellyfin/jellyfin:latest)
currentImageId?: string | null;
currentDigest?: string | null;
candidateImageId?: string | null; // après pull-check
candidateDigest?: string | null;
currentVersion?: string | null; // label OCI org.opencontainers.image.version
candidateVersion?: string | null;
sourceUrl?: string | null; // label OCI source
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; // services avec update dispo
declaredRoots?: string[];
stacks: DockerSnapshotStack[];
status?: SnapshotStatus;
}
export interface SnapshotError {
source: "apt" | "docker" | "post_install" | "ssh" | "system";
kind: string; // voir 50-erreurs.md (taxonomie)
severity: "info" | "warning" | "error";
message: string; // nettoyé, jamais de secret
remediation?: string;
importantLines?: string[];
}
export interface UpdateSnapshot {
machineId: string;
hostname: string;
os: { family: OsFamily; version: string };
checkedAt: string; // ISO 8601
status: MachineStatus;
apt: AptSnapshotDetail; // bloc jalon 1 conservé, champs additifs optionnels
// Ajouts optionnels (rétro-compatibles) :
schemaVersion?: number;
kind?: "apt_update_analyze" | "docker_scan" | "reboot_check" | "combined";
machineKind?: MachineKind;
docker?: DockerSnapshot;
errors?: SnapshotError[];
rawHints?: { logImportantLines: string[] };
}
```
> Le bloc `apt` reste **requis** (présent au jalon 1) ; seuls ses champs *additifs* sont optionnels. `docker`, `errors`, `machineKind`, `kind`, `schemaVersion` sont **optionnels** → un snapshot jalon 1 (sans eux) reste valide.
### Bloc Docker minimal exigé par la validation (couverture §6)
Le bloc snapshot Docker contient au minimum : stacks déclarés (`declaredRoots`), stacks candidats (`stacks[].status="candidate"`), services (`stacks[].services[]`), image ref actuelle (`image`), image ID actuelle (`currentImageId`), digest actuel si dispo (`currentDigest`), labels de version si dispo (`currentVersion`), image ID/digest candidat après pull (`candidateImageId`/`candidateDigest`), statut `up_to_date|updates_available|warning|error`. ✔
---
## 4. Résultat d'exécution étendu (`ExecutionResult`)
```ts
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[]; // ce qui était prévu (simulation pré-action)
applied: AptChange[]; // diff dpkg réel before/after
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>; // NON sensible uniquement
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"; // élargi (jalon 1 = "manual")
action: ActionType;
status: ExecutionStatus;
rebootRequiredAfterRun: boolean;
importantLogLines: string[];
rawLogRef: string;
reportRef: string;
// Ajouts optionnels (rétro-compatibles) :
schemaVersion?: number;
apt?: AptExecutionResult;
docker?: DockerExecutionResult;
reboot?: RebootResult;
postInstall?: PostInstallResult;
errors?: SnapshotError[];
}
```
> `mode` était `"manual"` (littéral) au jalon 1. L'élargir en union `"manual" | "scheduled" | "hermes_requested"` reste compatible (le jalon 1 émet toujours `"manual"`). Tous les nouveaux blocs (`apt`, `docker`, `reboot`, `postInstall`, `errors`) sont optionnels → une exécution jalon 1 reste valide.
---
## 5. Extension de `TemplateVars` (rendu Mustache)
```ts
export interface TemplateVars {
aptProxy?: string | null; // existant
// Ajouts (tous optionnels) :
osProfile?: OsFamily;
machineKind?: MachineKind;
confValues?: boolean;
inactivityTimeout?: number;
// Docker :
composeRoots?: string; // liste rendue shell-safe par le backend
composeScanDepth?: number;
stackDir?: string;
aggressive?: boolean; // prune agressif
// Custom :
operatorUser?: string;
packages?: string; // liste shell-safe
newHostname?: string;
interfaceName?: string;
staticAddress?: string;
reconnectHost?: string;
dockerUser?: string;
composeRoot?: string;
rebootAfterInstall?: boolean;
// ... champs de profil custom (typés au cas par cas en tâche 4)
}
```
---
## 6. Déduplication (empreinte fonctionnelle)
- **APT** : `dedupKey = os_family + "|" + package + "|" + from + "|" + to + "|" + origin`. Permet à Hermes de mutualiser une même mise à jour vue sur plusieurs machines (une seule recherche web, un seul résumé). Stocké dans `apt_planned_packages.dedup_key` / `apt_applied_packages.dedup_key`.
- **Docker** : `dedupKey = image + "|" + fromDigest + "|" + toDigest` ; fallback `image + "|" + fromImageId + "|" + toImageId` quand le digest manque.
Le calcul de `dedupKey` se fait **côté backend TS** (déterministe), pas dans le shell.
---
## 7. Réduction déterministe avant Hermes/MCP
Le réducteur actuel (`server/templates/aptReduce.ts`) garde les lignes : `Inst `, `Conf `, `Remv `, `Err `, `E:`, `W:`, `dpkg:`, `reboot-required`/`REBOOT_REQUIRED`. **Extension proposée** (renommage suggéré `reduceLines.ts`, additif, sans casser `reduceAptLines`) ajoutant les préfixes Docker : `Pulling`, `Digest`, `Status`, `Downloaded newer image`, `Recreating`, `Started`, `Error`, `deleted`, `Total reclaimed space`.
Ce que Hermes reçoit : **JSON canonique réduit** (`important_json`) + lignes importantes (`importantLogLines`). **Jamais** le log brut complet (archivé dans `raw_artifacts`/`rawLogPath`), jamais de secret.
---
## 8. Mapping vers `tache1.9.md` (tables dérivées)
| Bloc JSON | Table dérivée | Colonnes clés |
|---|---|---|
| `apt.packages` / `installed` / `removed` / `held` (simulation) | `apt_planned_packages` | `mode`, `operation`, `current_version`, `target_version`, `origin`, `dedup_key` |
| `apt.applied` (diff dpkg) | `apt_applied_packages` | `from_version`, `to_version`, `operation`, `dedup_key` |
| `errors[]` source apt | `apt_errors` | `kind`, `severity`, `message`, `important_lines_json`, `remediation` |
| `docker.stacks[]` | `docker_compose_stacks` + `docker_stack_services` | `status`, `detected_by`, `current_image_id`, `candidate_digest`, … |
| `docker.pull/up/prune changes` | `docker_image_events` | `from_image_id`, `to_image_id`, `operation`, `bytes_reclaimed` |
| lignes importantes/notices | `important_messages` | `source`, `category`, `package_name`, `message` |
| payload complet snapshot | `snapshots.payload_json` + `important_json` | `kind`, `schema_version`, `status` |
| payload complet exécution | `executions.result_json` + `important_json` | `error_kind`, `error_message`, `exit_code` |
Le JSON complet reste la vérité canonique (archivé) ; les tables dérivées servent recherche/filtres/dédup/badges (conforme à la règle structurante `tache1.9.md §2`).
+77
View File
@@ -0,0 +1,77 @@
# 50 — Taxonomie des erreurs + stratégie de remédiation
> Axe C + livrable §4.4. Codes d'erreur normalisés alignés sur `SnapshotError.kind` (`40-contrats-json.md`) et `apt_errors.kind` (`tache1.9.md`).
---
## 1. Principes
- **L'exit code ne suffit jamais** à déclarer un succès (APT comme Docker). On corrèle exit code + sections parsées + diff réel + lignes importantes.
- **Statut normalisé** : `ok` | `warning` | `error`. `warning` = succès partiel ou effet à surveiller (suppressions de paquets, held back, orphans removed, conteneur unhealthy).
- **Capture des lignes pertinentes** : seules les lignes d'erreur utiles (`E:`, `W:`, `dpkg:`, `Error`, etc.) sont remontées ; le log brut complet reste archivé.
- **Pas d'auto-réparation dangereuse non validée** : on **propose** une remédiation, on ne l'exécute pas automatiquement (`dpkg --configure -a`, `rm /var/lib/dpkg/lock`, etc. exigent validation explicite).
- **Nettoyage des secrets** : toute ligne d'erreur exposant une URL d'auth, un token ou un chemin de credential est nettoyée avant UI/MCP (voir `70-securite.md`).
---
## 2. Taxonomie APT / dpkg
| `kind` | Détection (lignes/exit) | Sévérité | Remédiation proposée (non auto) |
|---|---|---|---|
| `apt_lock_busy` | `Could not get lock /var/lib/dpkg/lock`, `E: Unable to acquire the dpkg frontend lock` | error | Attendre la fin d'un apt/unattended-upgrades concurrent ; relancer. |
| `dpkg_interrupted` | `dpkg was interrupted`, `dpkg --configure -a` | error | Proposer `dpkg --configure -a` **avec validation explicite**. |
| `repo_unreachable` | `Failed to fetch`, `Could not resolve`, `Connection timed out`, `E: Some index files failed to download` | error | Vérifier réseau/proxy apt-cacher-ng/dépôt ; retenter. |
| `gpg_key_error` | `NO_PUBKEY`, `EXPKEYSIG`, `signatures couldn't be verified` | error | Vérifier/mettre à jour la clé du dépôt ; ne pas désactiver la vérif. |
| `package_conflict` | `Depends:`, `but it is not going to be installed`, `held broken packages` | warning/error | Examiner le conflit ; éventuel `dist-upgrade` ; validation. |
| `disk_space_low` | `E: You don't have enough free space`, `No space left on device` | error | Libérer de l'espace (`apt-get clean`), vérifier `/`, `/var`, `/boot`. |
| `packages_held` | `apt-mark showhold` non vide / présents en dist-upgrade absents d'upgrade | warning | Information : paquets retenus ; nécessite dist-upgrade ou hold explicite. |
| `packages_removed` | `Remv ` en simulation/diff | warning | Suppression de paquets ⇒ confirmation UI obligatoire. |
| `human_interaction_required` | timeout d'inactivité atteint, prompt conffile/debconf/needrestart/apt-listchanges détecté | error | Reprise manuelle ; ne pas forcer ; lignes importantes fournies. |
| `kernel_partial_config` | `needrestart` signale services à redémarrer, `reboot-required` | warning | Planifier `reboot_verified`. |
| `apt_unknown_error` | exit ≠ 0 sans motif identifié | error | Consulter le log brut archivé. |
### Gestion des interactions humaines (couverture §7)
- Upgrades réels : `DEBIAN_FRONTEND=noninteractive`, `apt-get -y`, `-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold`.
- Justification politique par défaut : **conserver les fichiers de config locaux** quand dpkg ne peut pas trancher (ne pas écraser une config distante).
- Prompts potentiels (conffile, debconf, apt-listchanges, needrestart, service restart, maintainer script) = **risques de blocage à détecter**, jamais des dialogues exposés dans le terminal.
- **Timeout d'inactivité** (`inactivityTimeout`, défaut 600 s) et/ou **timeout global** → classer l'exécution en erreur contrôlée `human_interaction_required` si une action reste bloquée. Le backend (couche SSH) détecte l'absence de nouvelles données et coupe proprement, en marquant l'exécution.
---
## 3. Taxonomie Docker (alignée §6 de la validation)
| `kind` | Détection | Sévérité | Remédiation |
|---|---|---|---|
| `docker_not_installed` | `docker: command not found`, exit 127 | error | Proposer profil `docker_official`. |
| `compose_not_found` | dossier/fichier compose absent, `no configuration file provided` | error | Vérifier `composeRoots`/chemin du stack. |
| `compose_config_invalid` | `docker compose config --quiet` exit ≠ 0 | error | Corriger le fichier compose ; stack reste `candidate`. |
| `registry_auth_failed` | `unauthorized`, `denied`, `pull access denied` | error | Vérifier l'auth registry **sur la machine** (jamais lire les creds) ; message nettoyé. |
| `pull_failed` | `Error response from daemon`, `manifest unknown`, timeout | warning/error | Vérifier réseau/tag/registry ; retenter. |
| `image_inspect_failed` | `No such image`, exit ≠ 0 sur inspect | warning | Image absente localement ; relancer pull-check. |
| `up_failed` | `docker compose up` exit ≠ 0 | error | Examiner logs du service ; conserver l'ancien conteneur. |
| `container_unhealthy` | health `unhealthy` après up | warning | Surveiller ; proposer rollback manuel. |
| `prune_failed` | `docker image prune` exit ≠ 0 | warning | Vérifier l'état du daemon. |
| `disk_space_low` | `no space left on device` pendant pull/up | error | Prune sûr, libérer de l'espace. |
---
## 4. Taxonomie réseau / post-install (couverture §8)
| `kind` | Sévérité | Remédiation |
|---|---|---|
| `network_config_invalid` | error | Restaurer la sauvegarde `/etc/network/interfaces.su.bak.*`. |
| `interface_not_found` | error | Vérifier `interfaceName` (sonde `machine_probe`). |
| `dns_config_failed` | warning | Vérifier `resolvconf`/`dnsNameservers`. |
| `reconnect_failed` | error | La webapp retente sur `reconnectHost` ; rollback si échec. |
| `hostname_failed` | error | Vérifier droits / `hostnamectl`. |
| `sudo_setup_failed` | error | Reprendre `bootstrap_root` depuis un contexte root. |
---
## 5. Robustesse, idempotence, reprise
- **Idempotence** : les templates de détection (`update-analyze`, `docker_scan`, `inspect`, `pull-check`) sont rejouables sans effet de bord applicatif (pull-check écrit dans le cache images mais ne démarre rien — rejouable).
- **Opérations longues** : voir `90-questions-investigation.md` Q6. MVP : `nohup` + fichier exit-code pour les actions applicatives longues (full-upgrade, docker apply), reboot vérifié via boot_id ; refresh reste synchrone court.
- **Reprise** : une exécution coupée laisse un état lisible (sections déjà émises + exit-code sur disque côté machine). Le backend peut relire l'état au lieu de tout relancer.
- **Verrous** : `machine_locks` (`tache1.9.md`) empêche deux actions concurrentes destructives sur une même machine (`apt`, `docker`, `reboot`, `exclusive`).
+150
View File
@@ -0,0 +1,150 @@
# 60 — Profils OS, type machine, overrides et proxy APT
> Axe A + livrables §4.5/§4.6. Tranche §3.2 (structure profils OS) et §3.3 (profils machine). Couvre la grille §7 (« Profils OS et type de machine »).
---
## 1. Deux dimensions distinctes
- **`os_family`** : `debian` | `ubuntu` | `proxmox` | `raspbian` | `unknown` (quelle distro / quel jeu de dépôts et de commandes).
- **`machine_kind`** : `physical` | `vm` | `proxmox_host` | `lxc` | `raspberry_pi` | `workstation` | `unknown` (quel matériel / quels scripts pertinents : firmware, drivers, guest tools…).
Les deux sont **orthogonaux** : une Debian peut être physique, VM ou LXC ; un Raspberry Pi OS implique presque toujours `raspberry_pi`. Choisis **manuellement à l'ajout**, corrigeables par `machine_probe`. Stockés dans `machines.os_family` / `machine_kind` / `virtualization` / `hardware_profile` (`tache1.9.md §5`).
---
## 2. Arborescence des templates et héritage (décision §3.2)
**Convention de dossier + fallback `base`.** Le moteur de rendu choisit le template **le plus spécifique disponible**, sinon retombe sur le profil générique.
```text
templates/
├── apt/ # profil "base" (Debian/Ubuntu générique — jalon 1)
│ ├── update-analyze.sh.tpl
│ ├── upgrade.sh.tpl
│ ├── full-upgrade.sh.tpl
│ ├── autoremove.sh.tpl
│ ├── clean.sh.tpl
│ ├── reboot-check.sh.tpl
│ └── reboot.sh.tpl
├── proxmox/ # overrides Proxmox (dépôts PVE, kernel, Ceph)
│ ├── update-analyze.sh.tpl
│ └── full-upgrade.sh.tpl
├── raspbian/ # overrides Raspberry Pi OS (firmware, espace disque)
│ ├── update-analyze.sh.tpl
│ └── full-upgrade.sh.tpl
├── docker/
│ └── *.sh.tpl
└── custom/
└── *.sh.tpl
```
Résolution (pseudo-code backend) :
```text
resolveTemplate(action, osFamily):
candidate = templates/<osFamily>/<action>.sh.tpl
if exists(candidate): return candidate
return templates/apt/<action>.sh.tpl # fallback base
```
**Pourquoi pas un héritage par fragments/includes Mustache ?** Plus simple à auditer en Git (un fichier = un script complet, lisible et testable). Inconvénient : duplication partielle entre profils — accepté au MVP (peu de profils). Alternative notée en §3.2 de `90-questions-investigation.md`.
> **Non-régression jalon 1** : Debian/Ubuntu n'ont pas de dossier dédié ⇒ ils tombent sur `templates/apt/*` (comportement actuel inchangé). Le mécanisme de résolution est **additif**.
---
## 3. Overrides par machine
Au-delà du profil OS, chaque machine peut surcharger :
- `aptProxyMode` / `aptProxyUrl` (déjà présent au jalon 1) ;
- des variables de contexte (`composeRoots`, `inactivityTimeout`, etc.) ;
- l'activation de templates (`templates activables` côté formulaire) ;
- des presets de variables custom (`script_variables_presets` scope `machine`).
Priorité de résolution des variables : **override machine** > **défaut profil OS** > **défaut global**. Aucun override ne peut introduire un secret dans un template (les secrets restent côté `machine_credentials`, injectés uniquement via `runScriptSudo` stdin).
---
## 4. Spécificités par profil OS
### Debian
- `apt-get update` + `dist-upgrade` standard.
- Avant firmware/drivers propriétaires : vérifier `contrib`, `non-free`, `non-free-firmware` dans les sources (lecture seule, template `check-components`). Proposition uniquement.
- Source : https://www.debian.org/releases/bookworm/amd64/ch02s02.en.html
### Ubuntu
- Idem Debian + `ubuntu-drivers devices` (lecture) pour proposer des drivers (NVIDIA/GPU), surtout sur `machine_kind` physique/workstation/gpu. Jamais installé par défaut.
### Proxmox (profil dédié, jamais Debian générique)
- Contrôler les dépôts PVE : `pve-no-subscription` vs `enterprise` (sinon `apt update` échoue sur le dépôt entreprise sans abonnement).
- Meta-package `proxmox-ve`, kernel PVE, Ceph éventuel.
- `apt-get update` puis `apt-get dist-upgrade`.
- Source : https://pve.proxmox.com/wiki/System_Software_Updates
### Raspberry Pi OS (profil dédié)
- Attention firmware/kernel (`rpi-update` **non** utilisé par défaut — risqué).
- **Vérifier l'espace disque avant upgrade** (carte SD souvent petite).
- Utiliser `full-upgrade`.
- Source : https://www.raspberrypi.com/documentation/usage/terminal/
---
## 5. Influence du type machine sur les scripts proposés
| `machine_kind` | Scripts pertinents | À éviter |
|---|---|---|
| `physical` | détection hardware, firmware (`fwupd`), SMART/disques, sensors, drivers GPU, benchmark | guest tools |
| `vm` | guest tools (`qemu-guest-agent` ou `open-vm-tools`) | drivers GPU/firmware (sauf passthrough) |
| `proxmox_host` | profil Proxmox dédié (dépôts PVE, kernel, Ceph) | traitement Debian générique |
| `lxc` | minimal (pas de kernel/firmware propre au conteneur) | firmware, drivers, reboot kernel |
| `raspberry_pi` | profil RPi (firmware/kernel prudent, espace disque) | drivers GPU desktop |
| `workstation` / GPU server | drivers GPU (`ubuntu-drivers`/`nvidia`), benchmark | — |
> Les scripts hardware/drivers/benchmark ne sont **jamais installés par défaut** et **exigent validation explicite** (couverture §7).
---
## 6. Détection / correction : `machine_probe` (décision §3.3)
Action non destructive (lecture seule), proposée à l'ajout et relançable. Sources lues :
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:PROBE_OS==="
cat /etc/os-release 2>/dev/null
echo "===SU:PROBE_ARCH==="
uname -m
dpkg --print-architecture 2>/dev/null
echo "===SU:PROBE_VIRT==="
systemd-detect-virt 2>/dev/null || echo "none"
echo "===SU:PROBE_PROXMOX==="
[ -d /etc/pve ] && echo "PROXMOX=1" || echo "PROXMOX=0"
echo "===SU:PROBE_RPI==="
grep -qi raspberry /proc/cpuinfo 2>/dev/null && echo "RPI=1" || echo "RPI=0"
echo "===SU:PROBE_GPU==="
command -v lspci >/dev/null 2>&1 && lspci 2>/dev/null | grep -Ei 'vga|3d|display' || echo "no-lspci"
echo "===SU:PROBE_NET==="
ip -o -4 addr show 2>/dev/null | awk '{print $2, $4}'
echo "===SU:EXIT=0==="
```
Le backend propose une **correction** de `os_family`/`machine_kind`/`virtualization`/interface primaire, **jamais appliquée automatiquement** sans validation utilisateur (règle de correction : l'opérateur garde le dernier mot). Résultats persistés dans `machine_hardware` + colonnes `machines`.
**MVP retenu** : choix manuel à l'ajout (Debian/Ubuntu/Proxmox VE/Raspberry Pi OS/autre Linux ; VM/physique/Proxmox/LXC/RPi/GPU-workstation) + `machine_probe` pour proposer des corrections. Alternative (détection 100 % automatique) jugée trop fragile (cas limites : conteneurs imbriqués, distros dérivées) — voir `90-questions-investigation.md` Q3.
---
## 7. Proxy APT (apt-cacher-ng) — trois modes
`AptProxyMode = "direct" | "runtime" | "persistent"` (le `persistent` est l'ajout tâche 2).
| Mode | Mécanisme | Quand |
|---|---|---|
| `direct` | aucun proxy | défaut |
| `runtime` | `export http_proxy/https_proxy` dans le script (sections Mustache `{{#aptProxy}}`) — **comportement jalon 1, conservé** | proxy temporaire pour une exécution |
| `persistent` | écrire `Acquire::http::Proxy "<url>";` dans `/etc/apt/apt.conf.d/01proxy` (action dédiée, idempotente, sauvegarde de l'existant) | proxy permanent de la machine |
Le mode `persistent` est une **action explicite** (écriture sur disque) avec preview ; il n'est pas appliqué silencieusement. `runtime` reste injecté au rendu comme aujourd'hui.
+70
View File
@@ -0,0 +1,70 @@
# 70 — Note de sécurité : frontière Hermes/MCP et actions destructives
> Livrable §4.8. Tranche §3.7 (sécurité prune/scripts) et §3.8 (surface MCP). Aligné `CLAUDE.md` (sécurité non négociable) et `validation_tache2.md §3`/§6/§7/§8.
---
## 1. Ce qui ne doit JAMAIS atteindre Hermes / MCP / un prompt LLM
- Mots de passe SSH, **sudo password**, passphrases de clés, clés privées.
- Tokens / credentials de registry Docker (`~/.docker/config.json`, credential helpers, tokens d'auth).
- Toute variable de champ de type `secret` d'un profil post-install.
- URLs contenant des identifiants (`https://user:pass@…`), en-têtes d'auth, chaînes de connexion.
- Le **log brut complet** (archivé séparément, jamais inliné dans un prompt).
Mécanismes :
- Les secrets vivent uniquement dans `machine_credentials` (chiffrés au repos), injectés **uniquement** via `runScriptSudo` sur **stdin** (`sudo -S -p ''`) — jamais dans le corps du script, jamais en argument, jamais loggés.
- Le JSON canonique métier ne contient **aucun** champ secret (cf. `PostInstallResult.variablesUsed` = non sensible uniquement).
- **Nettoyage des erreurs** avant UI/MCP : un filtre déterministe masque les motifs sensibles (`https?://[^/@\s]+:[^/@\s]+@`, `Authorization:`, chemins `*/config.json`, `token=…`) dans les lignes d'erreur Docker/APT avant affichage et avant réduction Hermes.
---
## 2. Actions destructives → validation explicite côté webapp
Toute action modifiant l'état de la machine de façon non triviale passe par une **confirmation UI explicite** et est tracée comme `action_request` (`tache1.9.md §10`).
| Action | Risque | Validation |
|---|---|---|
| `apt_dist_upgrade` / `apt_full_upgrade` | peut supprimer des paquets | confirmation explicite si `removed`/`held` |
| `apt_autoremove` | supprime des paquets | confirmation explicite |
| `reboot` / `reboot_verified` | redémarrage | confirmation explicite |
| `docker_compose_apply` | recrée les conteneurs | confirmation explicite |
| `docker_prune_images` (agressif `-a`) | supprime des images non dangling | confirmation explicite distincte |
| `docker_compose_down` | arrête le stack | confirmation forte ; `--volumes`/`--rmi` interdits au MVP |
| `post_install:identity_network` | change le réseau / coupe la connexion | preview obligatoire + sauvegarde fichiers + stratégie de reconnexion planifiée |
| `apt_proxy persistent` | écrit dans `/etc/apt/apt.conf.d` | preview |
Règle d'or : **Hermes peut recommander/proposer, mais ne déclenche jamais directement** une action à risque. Le déclenchement passe par l'opérateur via l'UI (ou un `action_request` approuvé).
Actions sûres sans validation : `apt_update_analyze`, `docker_scan`, `docker_inspect_current`, `machine_probe`, `apt_clean`. `docker_pull_check` est **non passif** (écrit dans le cache images) mais non applicatif : pas de validation destructive, mais isolé du chemin de scan pur.
---
## 3. Surface MCP minimale (décision §3.8)
On **réutilise la surface v1** du rapport / `CLAUDE.md`, sans nouvelle primitive d'exécution SSH :
| Outil MCP | Rôle | Renvoie un secret ? |
|---|---|---|
| `list_machines` | liste les machines (vue publique, sans secret) | non |
| `get_machine_snapshot` | dernier snapshot réduit (APT + Docker) | non |
| `get_machine_execution` | résultat d'exécution réduit + réfs logs/rapport | non |
| `list_templates` | liste des templates disponibles | non |
| `preview_template` | rendu d'un template avec **masquage des secrets** | non |
| `run_refresh` | déclenche un refresh/analyse (action sûre) | non |
| `run_action` | déclenche une action **déjà autorisée** ; les actions destructives exigent une validation webapp préalable | non |
| `search_reports` | recherche dans les rapports archivés | non |
Principes :
- Le **MCP est une façade** de l'API métier : aucune logique SSH dedans, aucun secret.
- Aucun nouvel outil pour Docker/post-install : les nouvelles capacités passent par `run_action(actionType, params)` avec le **filtrage d'autorisation** de la route `POST /api/machines/:id/actions`. Surface stable et petite = agents fiables.
- `run_action` sur une action destructive non encore validée → renvoie un `action_request` en attente, **pas** une exécution.
- Audit : tout appel MCP est journalisé (`mcp_audit_log`, `tache1.9.md §11`) avec `request_json` réduit (sans secret).
---
## 4. Traçabilité
- Chaque exécution : log brut archivé (`raw_artifacts`/`rawLogPath`, `redacted=1`), rapport Markdown (`reports`), `important_json` réduit.
- Les changements réseau/Docker sont explicitement marqués dans le rapport avec les prochaines actions attendues (reconnexion, relogin groupe docker, reboot).
- Les secrets ne figurent ni dans les rapports, ni dans les logs UI, ni dans le MCP.
+89
View File
@@ -0,0 +1,89 @@
# 80 — Découpage en sous-jalons implémentables
> Livrable §4.9. Chaque sous-jalon = un cycle spec → plan → implémentation, indépendamment testable, sans casser le jalon 1. Priorisé. Prêt pour `writing-plans`.
---
## Ordre recommandé et dépendances
```text
SJ-0 (socle types/réduction) ──► SJ-1 (APT update/analyse) ──► SJ-2 (APT upgrade + diff)
SJ-3 (reboot vérifié)
SJ-4 (Docker scan/inspect) ──► SJ-5 (Docker pull-check) ──► SJ-6 (Docker apply/prune/down)
SJ-7 (profils OS Proxmox/RPi) [transversal, après SJ-1]
SJ-8 (post-install bootstrap/identité) [après SJ-0]
SJ-9 (post-install Docker officiel / partages / VM tools) [après SJ-8]
```
---
## SJ-0 — Socle : types étendus + réduction + résolution de profil
- **Contenu** : étendre `shared/types.ts` (unions + blocs optionnels, cf. `40-contrats-json.md`), étendre le réducteur (`reduceLines.ts` ajoutant les préfixes Docker), ajouter le mécanisme `resolveTemplate(action, osFamily)` avec fallback `base`, ajouter `schemaVersion`.
- **Testable** : tests unitaires de réduction (APT + Docker), tests de résolution de template, validation qu'un snapshot/exécution jalon 1 reste typé valide.
- **Risque** : faible (additif). **Priorité : 1 (prérequis de tout le reste).**
## SJ-1 — APT update/analyse (snapshot enrichi)
- **Contenu** : `apt/update-analyze.sh.tpl` (update + simulations upgrade/dist-upgrade + held + reboot-check), parsing des sections, `AptSnapshotDetail` enrichi, statut `ok|updates_available|warning|error`. Bascule du refresh dessus (en gardant `check.sh.tpl` jusqu'à validation).
- **Testable** : fixtures de sortie APT → snapshot ; non-régression du refresh jalon 1.
- **Risque** : faible-moyen (toucher le refresh). **Priorité : 2.**
## SJ-2 — APT upgrade / full-upgrade / autoremove / clean + diff dpkg réel
- **Contenu** : templates `upgrade`, `full-upgrade` (enrichi diff), `autoremove`, `clean` ; capture `DPKG_BEFORE/AFTER` ; calcul du diff réel (`AptExecutionResult`) ; timeout d'inactivité + `human_interaction_required` ; confirmations UI pour suppressions.
- **Testable** : fixtures dpkg before/after → diff ; détection des suppressions/held.
- **Risque** : moyen (actions destructives). **Priorité : 3.**
## SJ-3 — Reboot vérifié (boot_id + délai adaptatif)
- **Contenu** : `apt/reboot.sh.tpl` (capture boot_id) + orchestration backend (attente coupure, reconnexion, relecture boot_id), `RebootResult`, délai adaptatif par machine. Conserve l'action `reboot` jalon 1.
- **Testable** : simulation des états (`boot_id_unchanged`, `machine_did_not_return`, `timeout`).
- **Risque** : moyen. **Priorité : 4.**
## SJ-4 — Docker scan + inspect (passifs)
- **Contenu** : `docker/scan-compose.sh.tpl`, `docker/inspect-compose.sh.tpl` ; config machine `dockerEnabled`/`composeRoots`/`composeScanDepth` ; cycle `candidate`/`enabled` ; tables `docker_*`. Détection labels en complément.
- **Testable** : fixtures de scan → liste de stacks ; validation `compose config --quiet`.
- **Risque** : faible (passif). **Priorité : 5.**
## SJ-5 — Docker pull-check + comparaison
- **Contenu** : `docker/pull-check.sh.tpl` ; comparaison déterministe ID/digest/labels OCI ; `DockerSnapshot`/services ; dédup Docker ; refresh Docker séparé (non auto).
- **Testable** : fixtures before/after pull → updates détectées ; nettoyage secrets registry.
- **Risque** : faible-moyen. **Priorité : 6.**
## SJ-6 — Docker apply + prune + down
- **Contenu** : `apply-compose`, `prune-images` (safe/agressif), `down-compose` ; `DockerExecutionResult` ; validations UI explicites ; `docker_image_events`.
- **Testable** : fixtures up/prune → conteneurs recréés / bytes reclaimed.
- **Risque** : moyen-élevé (destructif). **Priorité : 7.**
## SJ-7 — Profils OS Proxmox + Raspberry Pi (+ proxy persistent)
- **Contenu** : dossiers `templates/proxmox/`, `templates/raspbian/` (update-analyze, full-upgrade) ; mode `AptProxyMode="persistent"` ; `machine_probe`.
- **Testable** : résolution de template par OS ; sonde → propositions de correction.
- **Risque** : faible (additif, fallback base préservé). **Priorité : 8 (transversal).**
## SJ-8 — Post-install : bootstrap + identité/réseau
- **Contenu** : moteur de profils (manifestes, champs dynamiques, preview, validations), `custom/bootstrap-root.sh.tpl`, `custom/identity-network.sh.tpl` ; `install_profiles`/`install_recipes` ; stratégie reconnexion ; `PostInstallResult`.
- **Testable** : rendu de manifeste → formulaire ; preview masquant les secrets ; échec contrôlé si champ manquant.
- **Risque** : moyen (réseau). **Priorité : 9.**
## SJ-9 — Post-install : paquets de base + Docker officiel + partages + VM tools
- **Contenu** : `install-package-groups`, `docker-official-debian`, `sharing`, `vm-guest-tools` ; presets de variables ; renvoi du catalogue détaillé à la tâche 4.
- **Testable** : installation de groupes ; idempotence.
- **Risque** : faible-moyen. **Priorité : 10.**
---
## Notes de séquencement
- **SJ-0 est bloquant** pour tous les autres (types + réduction + résolution).
- APT (SJ-1→3) et Docker (SJ-4→6) sont **indépendants** : peuvent être menés en parallèle après SJ-0.
- Chaque sous-jalon livre un logiciel testable et ne casse pas les flux jalon 1 (`refresh`, `apt_full_upgrade`, `reboot`) grâce aux extensions additives.
- Les actions destructives n'arrivent qu'après le socle de validation UI (`action_requests`), conformément à `70-securite.md`.
@@ -0,0 +1,72 @@
# 90 — Les 8 questions d'investigation (§3) tranchées
> Chaque question : **MVP recommandé / alternatives / risques**. Décisions autonomes argumentées, cohérentes avec l'existant et `tache1.9.md`.
---
## Q1 — JSON-in-shell vs parsing-in-TS
**MVP recommandé : hybride à dominante parsing-TS.** On conserve la convention actuelle (marqueurs `===SU:XXX===` + parsing dans `server/services/`). On enrichit avec des **données semi-structurées produites par le shell uniquement quand le format est déjà stable et documenté** : `dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n'` (TSV), `docker compose ps/images --format json`, `docker image inspect --format '...'`. Pas de construction de gros JSON imbriqué à la main dans le shell.
- **Pourquoi** : (a) cohérence avec le jalon 1 (déjà en prod, parsing TS testé) ; (b) testabilité — les fixtures de sortie shell + tests TS sont faciles à maintenir ; (c) robustesse multi-OS — éviter le JSON bricolé en Bash (échappement fragile, comme on le voit dans `nas-ops` avec la concaténation manuelle de chaînes JSON) ; (d) on tire parti des formats JSON **natifs et documentés** de Docker sans les réinventer.
- **Alternatives** : (1) tout-JSON-in-shell façon `nas-ops` — rejeté (échappement fragile, dur à tester, risque de casser sur des noms/versions exotiques) ; (2) tout-parsing-TS sur sortie brute uniquement — rejeté pour Docker où `--format json` est plus sûr que parser du texte libre.
- **Risques** : double convention (TSV/clé=valeur + sections) à documenter clairement ; mitigé par un parseur central par section. Format `docker ... --format json` varie selon version de Compose — pin de la commande + fallback texte.
## Q2 — Structure des profils OS
**MVP recommandé : un fichier de template complet par profil, dans un dossier par OS, avec fallback `base`** (`templates/<osFamily>/<action>.sh.tpl` → sinon `templates/apt/<action>.sh.tpl`). Résolution par convention de chemin (cf. `60-profils-os-machine.md §2`).
- **Pourquoi** : lisibilité et audit Git (un script = un fichier complet, testable isolément) ; n'invalide pas Debian/Ubuntu (pas de dossier dédié ⇒ fallback `apt/`, comportement jalon 1 intact).
- **Alternatives** : (1) héritage par fragments/partials Mustache (DRY) — plus complexe à auditer, reporté ; (2) une matrice de variables dans un seul template géant avec `{{#proxmox}}…` — rejeté (templates illisibles, logique métier noyée dans le rendu).
- **Risques** : duplication partielle entre profils. Accepté au MVP (peu de profils) ; refactor en partials possible plus tard si la duplication devient coûteuse.
## Q3 — Structure des profils machine
**MVP recommandé : choix manuel à l'ajout (`os_family` + `machine_kind`) + action `machine_probe` non destructive proposant des corrections** (jamais appliquées sans validation). Sources : `/etc/os-release`, `uname -m`/`dpkg --print-architecture`, `systemd-detect-virt`, présence `/etc/pve`, `/proc/cpuinfo` (RPi), `lspci` (GPU), `ip addr` (interface).
- **Pourquoi** : la détection auto seule est fragile (conteneurs imbriqués, distros dérivées, VM mal taguées). Le couple « défaut manuel + sonde de correction » est robuste et garde l'opérateur maître.
- **Alternatives** : (1) détection 100 % auto — rejetée (cas limites) ; (2) manuel sans sonde — perte de confort et risque d'erreur de profil.
- **Risques** : utilisateur choisit mal le profil ⇒ `machine_probe` le signale ; correction nécessite validation. Persisté dans `machines` + `machine_hardware`.
## Q4 — Capture avant/après (diff réel)
**MVP recommandé : snapshot dpkg complet avant ET après chaque action APT réelle** via `dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n'`, diff calculé côté backend (clé `package+arch`). L'exit code APT ne suffit jamais.
- **Pourquoi** : `dpkg-query` reflète l'état **réel** installé (pas l'intention APT). Détecte les écarts entre simulation et réalité (paquet annoncé non installé, held back effectif).
- **Alternatives** : (1) parser uniquement la sortie `apt-get` (`Setting up …`) — moins fiable, dépend du locale et du verbeux ; (2) historique `/var/log/dpkg.log` — parsing daté fragile. Les deux gardés comme signaux secondaires, pas comme source de vérité.
- **Risques** : sur de très gros parcs de paquets, deux `dpkg-query` ajoutent quelques secondes — négligeable. Diff côté TS = testable par fixtures.
## Q5 — Contrats JSON (extensions exactes)
**MVP recommandé : extensions additives détaillées dans `40-contrats-json.md`** — unions élargies (`OsFamily`, `AptProxyMode`, `ActionType`, `MachineKind`), blocs optionnels `docker`/`errors`/`reboot`/`postInstall` + champs additifs sur `apt`, `schemaVersion?`. Types TS fournis. Un payload jalon 1 reste valide.
- **Pourquoi** : rétro-compatibilité stricte exigée par le gate (§3 de la validation). Champs optionnels + unions additives = zéro rupture.
- **Alternatives** : versionner par type séparé (`UpdateSnapshotV2`) — rejeté (duplication, migration lourde) au profit de `schemaVersion` sur un type unique.
- **Risques** : un type unique grossit ; mitigé par découpe en sous-interfaces (`AptSnapshotDetail`, `DockerSnapshot`, etc.).
## Q6 — Idempotence & opérations longues
**MVP recommandé : différencier selon la durée et le risque.**
- **Refresh/analyse, scan, inspect** : synchrones et courts (comme le jalon 1).
- **Actions applicatives longues** (`full-upgrade`, `docker apply`) : généraliser l'exécution détachée `nohup` + **fichier exit-code sur la machine** (survit à une coupure SSH), comme prévu au jalon 1 et inspiré de `linux-update-dashboard`. Le backend peut relire l'état/exit-code à la reconnexion plutôt que tout relancer.
- **Reboot** : mécanisme dédié `reboot_verified` (boot_id avant/après + reconnexion + délai adaptatif).
- **Idempotence** : les détections sont rejouables sans effet de bord applicatif ; `pull-check` écrit dans le cache images mais ne démarre rien (rejouable). `machine_locks` évite la concurrence destructive.
- **Alternatives** : (1) tout synchrone — rejeté (un upgrade long meurt avec la session SSH) ; (2) file de jobs persistante dès le MVP — utile mais relève de la **tâche 5** ; ici on pose le mécanisme `nohup`+exit-code et on renvoie l'orchestration job à la tâche 5.
- **Risques** : suivi de progression d'une opération détachée = tailing du fichier de sortie distant + WebSocket ; à câbler proprement en implémentation (réutilise `outputHub`).
## Q7 — Sécurité Docker `prune` / scripts custom
**MVP recommandé : barrière de validation côté webapp (`action_requests`) pour toute action destructive + nettoyage déterministe des secrets dans les erreurs.** `docker_prune_images -a`, `docker_compose_down`, `docker_compose_apply`, suppressions APT, `reboot`, `identity_network` ⇒ confirmation explicite. Hermes propose, ne déclenche jamais. Credentials registry/sudo/tokens jamais lus ni renvoyés (cf. `70-securite.md`).
- **Pourquoi** : respecte `CLAUDE.md` (actions destructives validées, aucun secret vers LLM). La barrière unique (`action_requests`) centralise l'autorisation côté API.
- **Alternatives** : validation par template (flag `requiresConfirmation` dans le manifeste) — complémentaire, pas suffisant seul ; la décision d'autorisation reste côté API/route.
- **Risques** : un script custom pourrait logger un secret ⇒ règle « pas de secret dans le corps du script » + filtre de nettoyage des lignes avant UI/MCP + revue Git des templates.
## Q8 — Surface MCP
**MVP recommandé : conserver la surface v1 (8 outils), sans nouvelle primitive d'exécution SSH.** Les nouvelles capacités (Docker, post-install, APT détaillé) passent par `run_action(actionType, params)` filtré côté route, pas par de nouveaux outils. `preview_template` masque les secrets. `run_action` sur action destructive non validée renvoie un `action_request` en attente. Audit via `mcp_audit_log`.
- **Pourquoi** : surface petite = agents fiables et auditables ; le MCP reste une **façade** sans logique SSH ni secret.
- **Alternatives** : exposer un outil par action (`docker_apply`, `apt_upgrade`…) — rejeté (explosion de surface, duplication d'autorisation).
- **Risques** : `run_action` générique doit valider strictement `actionType`/`params` côté API (liste blanche) ; sinon risque d'action non prévue. Mitigé par la table d'autorisation et `action_requests`.
+182
View File
@@ -0,0 +1,182 @@
# 99 — Auto-évaluation de couverture du gate `validation_tache2.md`
> Relecture case par case. ✅ = couvert ; ⚠️ = couvert avec réserve / hors périmètre design (à confirmer en implémentation). Légende des renvois : fichiers de `docs/design/tache2/`.
---
## §1 Discipline & périmètre
| Case | État | Renvoi |
|---|---|---|
| Aucun code de production modifié (server/client/shared/templates/configs) | ✅ | Seuls `docs/design/tache2/**` + section clôture `tache2.md` créés. À vérifier par `git status`. |
| Jalon 1 et jalon 2 intacts | ✅ | Aucun fichier de jalon touché. |
| Aucun autre chantier hors périmètre | ✅ | Hors-scope listés comme suggestions (`00-synthese.md §6`). |
| Dépôts de référence non copiés | ✅ | `nas-ops`/`linux-update-dashboard` cités en inspiration, pseudo-shell réécrit. |
## §2 Complétude — Axes
| Axe | État | Renvoi |
|---|---|---|
| A — Templates APT + sémantique + profils OS + proxy | ✅ | `10-templates-apt.md`, `60-profils-os-machine.md` |
| B — Capture prévu/appliqué consommable Hermes | ✅ | `40-contrats-json.md` (snapshot/diff/dédup/réduction) |
| C — Taxonomie erreurs + remédiation | ✅ | `50-erreurs.md` |
| D — Docker scan/pull/up/down/prune + détection + JSON | ✅ | `20-docker.md` |
| E — Scripts personnalisés + overrides + garde-fous | ✅ | `30-scripts-custom.md` |
## §2 Complétude — Livrables §4
| Livrable | État | Renvoi |
|---|---|---|
| Inventaire des templates | ✅ | `10` §2, `20` §2, `30` §2/§4 |
| Contenu proposé (pseudo-shell, `===SU:XXX===`) | ✅ | `10` §4, `20` §4, `30` §4 |
| Schémas JSON canoniques étendus | ✅ | `40` |
| Taxonomie des erreurs | ✅ | `50` |
| Modèle profils OS + overrides | ✅ | `60` |
| Modèle profils machine | ✅ | `60` §5 |
| Modèle scripts personnalisés | ✅ | `30` |
| Note de sécurité | ✅ | `70` |
| Découpage en sous-jalons priorisé | ✅ | `80` |
## §2 — 8 questions d'investigation
| | État | Renvoi |
|---|---|---|
| Q1Q8 tranchées (MVP/alternatives/risques) | ✅ | `90-questions-investigation.md` |
## §3 Cohérence & intégration
| Case | État | Renvoi |
|---|---|---|
| Types JSON compatibles `shared/types.ts`, rétro-compatibles | ✅ | `40` §1–§4 (champs optionnels, payload jalon 1 valide) |
| Convention templates (`===SU:`, `LC_ALL=C`, `sudo -S`, parsable) | ✅ | `10`/`20`/`30` |
| Parsing (JSON-in-shell vs TS) explicite et justifié | ✅ | `90` Q1, `40` §7 |
| Couche SSH réutilisée (`server/ssh/client.ts`) | ✅ | `00` §4, `90` Q6 |
| Frontière Hermes/MCP, réduction déterministe | ✅ | `70`, `40` §7 |
| Sécurité actions destructives + pas de secret | ✅ | `70` §1/§2 |
| Profils OS n'invalident pas Debian/Ubuntu prod | ✅ | `60` §2 (fallback `base`) |
| Sous-jalons indépendamment implémentables | ✅ | `80` |
## §4 Non-régression
| Case | État | Note |
|---|---|---|
| `pnpm check`/`test`/`build` verts | ⚠️ | Hors périmètre design (aucun code touché) ; à exécuter par l'orchestrateur. Aucune modification de code n'a été faite. |
| Flux jalon 1 inchangés | ✅ | Extensions additives uniquement ; templates jalon 1 non modifiés. |
## §6 Focus Docker Compose
| Case | État | Renvoi |
|---|---|---|
| Gestion par SSH, réutilise couche existante, `docker context` = alternative | ✅ | `20` §1 |
| Stacks depuis racines déclarées `composeRoots`, scan limité, validation UI | ✅ | `20` §1/§4.1 |
| Détection labels en complément | ✅ | `20` §1/§4.1 |
| Stack détecté = `candidate`, actions sur `enabled` seulement | ✅ | `20` §1 |
| `scan-compose.sh.tpl` (fichiers compose, ignore .git/node_modules/backup/old/archive, `config --quiet`) | ✅ | `20` §4.1 |
| `inspect-compose.sh.tpl` (`config --images`, `ps --format json`, `images --format json`, `image inspect`) | ✅ | `20` §4.2 |
| `pull-check.sh.tpl` (`pull --policy always --ignore-buildable`, compare ID/digest/labels, non passif) | ✅ | `20` §4.3, §1 tableau |
| `apply-compose.sh.tpl` (`up -d --remove-orphans`, recapture) | ✅ | `20` §4.4 |
| `prune-images.sh.tpl` (safe `-f`, agressif `-a -f --filter until=168h` validé) | ✅ | `20` §4.5 |
| `down-compose.sh.tpl` (séparé/destructif, `--volumes`/`--rmi` interdits) | ✅ | `20` §4.6 |
| Flux 1→8 formalisé | ✅ | `20` §3 |
| `pull` télécharge sans démarrer | ✅ | `20` §3 |
| `up -d` recrée si changement, préserve volumes, `down` inutile | ✅ | `20` §3 |
| `prune -f` vs `-a` (destructif) | ✅ | `20` §2/§3 |
| Sources Docker citées | ✅ | `20` §1 |
| Snapshot Docker rétrocompatible (bloc optionnel) | ✅ | `40` §3 |
| Bloc snapshot Docker (stacks/services/ID/digest/labels/candidat/statut) | ✅ | `40` §3 (« bloc Docker minimal ») |
| `ExecutionResult.docker` (pull/up/prune/erreurs/recréés/supprimés/octets) | ✅ | `40` §4 |
| Erreurs Docker structurées (10 codes) | ✅ | `50` §3 |
| Réduction Hermes (lignes Docker) + log brut archivé | ✅ | `20` §5, `40` §7 |
| Config machine (`dockerEnabled`/`composeRoots`/`composeScanDepth`/`composeStacks[]`) sans casser `MachineView` | ✅ | `20` §6, `40` §5 |
| Refresh combiné apt+docker ou Docker séparé | ✅ | `20` §6 |
| `ActionType` étendu (docker_*) + filtrage autorisation | ✅ | `40` §2, `20` §6 |
| Réutilise `executions`/WS/`rawLogPath`/`reportPath`/statut | ✅ | `20` §6 |
| UI compteur Docker séparé + détail + boutons validés | ✅ | `20` §6 |
| Validation UI apply/prune agressif/down ; Hermes ne déclenche pas | ✅ | `20` §6, `70` §2 |
| Secrets registry jamais lus ; erreurs nettoyées | ✅ | `20` §6, `70` §1 |
## §7 Focus APT/reboot
| Case | État | Renvoi |
|---|---|---|
| `apt_update_analyze` distinct des upgrades destructifs | ✅ | `10` §2, `40` §2 |
| update + `-s upgrade` + `-s dist-upgrade` | ✅ | `10` §4.1 |
| Snapshot liste paquets prévus (nom/cur/cible/origine/arch) | ✅ | `10` §4.1, `40` §3 |
| Distingue upgrade vs full/dist (maj/install/remove/held) | ✅ | `10` §4.1, `40` §3 |
| Simulations parsées via `Inst`/`Conf`/`Remv`, log brut archivé | ✅ | `10` §1/§4.1 |
| Statut `ok/updates_available/warning/error`, warning si remove/held | ✅ | `10` §4.1, `40` §3 |
| Sources APT citées | ✅ | `10` §1 |
| Distingue `os_family` et `machine_kind` à l'ajout | ✅ | `60` §1/§6 |
| Choix manuel OS (Debian/Ubuntu/Proxmox/RPi/autre) | ✅ | `60` §6 |
| Choix manuel type (VM/physique/Proxmox/LXC/RPi/GPU-workstation) | ✅ | `60` §6 |
| `machine_probe` détecte/corrige | ✅ | `60` §6 |
| Scripts dépendent du couple OS/type | ✅ | `60` §5 |
| Debian firmware vérifie contrib/non-free/non-free-firmware | ✅ | `60` §4 |
| Proxmox = profil dédié | ✅ | `60` §2/§4 |
| Scripts hardware/drivers/benchmark jamais par défaut, validation | ✅ | `60` §5 |
| Templates APT attendus (update-analyze/upgrade/full-upgrade/autoremove/clean/reboot-check/reboot) | ✅ | `10` §2/§4 |
| Politique non interactive (`noninteractive`, `-y`, confdef/confold) | ✅ | `10` §4.2, `50` §2 |
| Justification confdef/confold | ✅ | `10` §4.2, `50` §2 |
| Prompts traités comme risques de blocage | ✅ | `50` §2 |
| Timeout inactivité/global → erreur contrôlée | ✅ | `50` §2 |
| `human_interaction_required` prévu | ✅ | `50` §2 |
| Pas seulement exit code | ✅ | `50` §1, `10` §4.2 |
| dpkg-query before/after | ✅ | `10` §4.2 |
| Diff backend (maj/install/remove/inchangé/versions/anomalies) | ✅ | `40` §4, `90` Q4 |
| `ExecutionResult.apt` (planned/applied/installed/removed/held/errors/reboot) | ✅ | `40` §4 |
| Rapport MD résume diff + réf log | ✅ | `70` §4, `40` §8 |
| Reboot vérifié (boot_id avant/après, attente, reconnexion) | ✅ | `10` §4.5, `40` §4 |
| Reboot ok si revient ET boot_id changé | ✅ | `10` §4.5 |
| `RebootResult` (beforeBootId…status/errors) | ✅ | `40` §4 |
| Délai adaptatif par machine | ✅ | `10` §4.5, `40` §4 |
| Statuts d'échec reboot distingués | ✅ | `40` §4 (`RebootResult.status`) |
| Reboot = action validée ; Hermes ne déclenche pas | ✅ | `70` §2 |
| `apt_update_analyze` alimente snapshot + tuile | ✅ | `10` §6, `20` §6 |
| Actions via même route + table `executions` | ✅ | `20` §6, `10` §6 |
| UI avant exécution (paquets/suppressions/held/reboot/risque) | ✅ | `70` §2, `40` §3 (renvoi tâche 3 pour le rendu) |
| UI après exécution (réussite/diff/reboot/rapport/log) | ✅ | `70` §4 (renvoi tâche 3) |
| Confirmation UI pour dist/full/autoremove/reboot | ✅ | `70` §2 |
| Nouveaux champs/actions rétrocompatibles | ✅ | `40` §1/§2 |
## §8 Focus post-install
| Case | État | Renvoi |
|---|---|---|
| Interdit questions interactives SSH → champs formulaire | ✅ | `30` §1 |
| Profils cochables dépliant leurs champs | ✅ | `30` §1/§3 |
| Manifeste (`id`/`label`/`description`/`fields`/défauts/validations/preview/risk/confirmations) | ✅ | `30` §1/§3 |
| Bouton désactivé si champs invalides | ✅ | `30` §1 |
| Preview avec masquage secrets + signalement réseau/reboot | ✅ | `30` §1, `70` §1 |
| Échec structuré si décision manquante | ✅ | `30` §1/§4 |
| Profils attendus (bootstrap_root/identity_network/base_tools/network_tools/dev_git/sharing/docker_official/vm_guest_tools + optionnels) | ✅ | `30` §2 |
| Champs `identity_network` | ✅ | `30` §3 |
| Champs `docker_official` | ✅ | `30` §3 |
| Champs `sharing` | ✅ | `30` §3 |
| Champs `vm_guest_tools` | ✅ | `30` §3 |
| Champs préremplis modifiables | ✅ | `30` §3 |
| Exemple de manifeste | ✅ | `30` §3 |
| Templates custom attendus (bootstrap/identity/install-package-groups/docker-official/sharing/vm-guest-tools) | ✅ | `30` §4 |
| Sources citées | ✅ | `30` §4 |
| identity_network à risque (confirmation/preview/sauvegardes) | ✅ | `30` §4.2, `70` §2 |
| Résultat JSON ancien/nouveau endpoint + reconnectHost | ✅ | `40` §4 (`PostInstallResult.networkChange`), `30` §4.2 |
| Pas de coupure sans stratégie reconnexion ; reboot via reboot_verified | ✅ | `30` §4.2 |
| Webapp vérifie reconnexion + met à jour machine | ✅ | `30` §4.2 |
| Erreurs réseau distinguées (6 codes) | ✅ | `50` §4 |
| `ExecutionResult.postInstall` rétrocompatible | ✅ | `40` §4 |
| Résultat liste profils/variables non sensibles/fichiers/paquets/services/reboots/erreurs | ✅ | `40` §4 |
| Secrets jamais inclus | ✅ | `30` §5, `70` §1 |
| Changements réseau/Docker marqués dans rapport MD | ✅ | `30` §5, `70` §4 |
| Même mécanique (templates/preview/SSH/WS/executions/rapport/log) | ✅ | `30` §6 |
| Valeurs réutilisables stockées (où) | ✅ | `30` §6 (`script_variables_presets`/`machine_profile_state`) |
| Hermes propose/explique, JSON réduit, pas de déclenchement risqué | ✅ | `30` §6, `70` §2/§3 |
| Profils découpés en sous-jalons indépendants | ✅ | `80` SJ-8/SJ-9 |
---
## Réserves résiduelles (⚠️)
1. **Non-régression build/tests (§4)** : non exécutée dans cette mission de design (aucun code touché, par consigne). L'orchestrateur doit lancer `pnpm check/test/build` pour confirmer 0 régression — attendu vert puisque aucune modification de code.
2. **Rendu UI fin (§7/§8 « UI avant/après »)** : le design pose les données et les exigences ; le rendu visuel exact relève de la **tâche 3**. Couvert au niveau contrat/exigence, pas au niveau JSX.
3. **Détails Mustache vs Go-templates Docker** : les `{{ }}` de `docker inspect --format` entrent en conflit avec Mustache ; le pseudo-shell le signale (échappement) — choix de délimiteurs à figer en implémentation (SJ-4).
Aucune réserve bloquante identifiée. Verdict visé : **✅ Accepté**.
@@ -1,5 +1,7 @@
# Jalon 2 — Polish design system — Implementation Plan
> **⚠️ STATUT (2026-06-05) : ABSORBÉ PAR LA TÂCHE 3.** La roadmap `liste_taches.md` / `coherence_taches.md` regroupe tout le frontend (layout, tuiles, volet Hermes, terminal, paramètres, thème, status bar, icônes) dans la **tâche 3 (design frontend)**, gate `validation_tache3.md`. Ce plan jalon-2 reste valide comme **matériau d'implémentation du polish** : le wiring DS (exports ESM + Font Awesome + polices, Tasks 1-4) est **déjà commité** et acquis ; les Tasks 5-12 (Header, StatusBar, refonte MachineTile/AddMachineModal/TerminalPanel/Dashboard/App) seront **implémentées plus tard dans le cadre de la tâche 3**, après validation de son design. Ne pas exécuter ce plan isolément.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refondre l'UI existante avec les composants du design system Gruvbox (Button, IconButton, StatusLed, Popup), brancher Font Awesome + les polices en offline, ajouter un header (titre + ajout + bascule thème) et une status bar tmux, et rendre le terminal non ambigu entre machines.
@@ -0,0 +1,659 @@
# Tâche 1.9 — Phase 1 (schéma BDD socle) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implémenter la Phase 1 du schéma BDD cible (`tache1.9.md §14`) : étendre `machines/snapshots/executions`, créer les tables socle (`machine_state`, `machine_hardware`, `machine_metrics_latest`, `machine_events`, `important_messages`, `reports`, `raw_artifacts`), et alimenter l'état dérivé + la timeline lors des refresh/exécutions existants.
**Architecture:** Extension additive (rétro-compatible) du schéma Drizzle/SQLite. Migration générée par drizzle-kit. Un service `machineState` dérive l'état courant d'une machine depuis un snapshot/exécution et l'« upsert » dans `machine_state` ; `refreshMachine` et `runAction` (existants) sont enrichis pour peupler `machine_state`, `machine_events`, et (pour les exécutions) `reports` + `raw_artifacts`, ainsi que les nouveaux champs `kind/schema_version/important_json` des snapshots/exécutions. **Aucune modification de l'API ni du frontend** (réservé tâches 3/5).
**Tech Stack:** Drizzle ORM, better-sqlite3, drizzle-kit, vitest.
---
## Contexte & invariants
- État actuel : `server/db/schema.ts` contient `machines`, `snapshots`, `executions` (jalon 1, en prod). Voir le fichier.
- **Rétro-compatibilité stricte** : on AJOUTE des colonnes/tables ; on ne renomme ni ne supprime rien. En particulier on conserve `snapshots.checked_at` (le design tache1.9 le nomme `created_at`, mais le code jalon 1 `refresh.ts` utilise `checkedAt` — on ne casse pas).
- Les nouveaux champs sont nullable ou ont une valeur par défaut, pour que les lignes du jalon 1 restent valides.
- `payload_json` / `result_json` restent la vérité canonique ; `machine_state` n'est qu'un cache dérivé pour l'UI (jamais source de vérité métier).
- Pas de FK vers des tables non encore créées (jobs/action_requests/schedules = phases ultérieures) : `running_job_id`, `request_id`, `job_id` sont de simples colonnes `text` nullable.
- Ne pas committer (l'utilisateur gère les commits en fin de parcours). Les étapes « Commit » du template sont **remplacées par une vérification** ; ne PAS exécuter `git commit`.
> **Note exécution** : ce plan se construit sur l'état courant du working tree (qui contient du WIP non commité : feature `capabilities`, scaffold Rust). Ne pas annuler ce WIP. Les fichiers touchés ici (`server/db/*`, `server/services/*`) ne chevauchent pas le WIP `capabilities`/frontend.
---
## File Structure
```
server/db/
├─ schema.ts # MODIF : +colonnes machines/snapshots/executions, +7 tables
├─ migrations/ # +1 migration générée (drizzle-kit)
└─ schema.test.ts # NOUVEAU : test que la migration applique le schéma cible
server/services/
├─ machineState.ts # NOUVEAU : dériver + upsert machine_state, insert events
├─ machineState.test.ts # NOUVEAU : tests purs de dérivation
├─ refresh.ts # MODIF : peupler snapshot.kind/schema_version/important_json + machine_state + event
└─ execute.ts # MODIF : champs executions + machine_state + event + reports + raw_artifacts
```
---
## Task 1 : Étendre le schéma Drizzle + migration
**Files:**
- Modify: `server/db/schema.ts`
- Create: migration sous `server/db/migrations/` (générée)
- Create: `server/db/schema.test.ts`
- [ ] **Step 1 : Remplacer le contenu de `server/db/schema.ts`**
> **⚠️ Tree déplacé** : `schema.ts` contient déjà la table `apiClients` (WIP api_clients, migration `0001_api_clients.sql`). Le contenu ci-dessous **préserve `apiClients`** (et l'import `uniqueIndex`). NE PAS supprimer `apiClients`. La migration générée à l'étape suivante sera donc `0002_*` (et non `0001`).
```ts
// server/db/schema.ts
import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
export const machines = sqliteTable("machines", {
id: text("id").primaryKey(),
name: text("name").notNull(),
hostname: text("hostname").notNull(),
port: integer("port").notNull().default(22),
osFamily: text("os_family").notNull().default("unknown"),
osVersion: text("os_version"),
osCodename: text("os_codename"),
arch: text("arch"),
machineKind: text("machine_kind"), // physical | vm | proxmox_host | lxc | raspberry_pi | workstation | unknown
virtualization: text("virtualization"), // none | qemu | kvm | lxc | docker | vmware | ...
hardwareProfile: text("hardware_profile"), // generic_vm | baremetal_server | raspberry_pi | gpu_server | proxmox_host | ...
username: text("username").notNull(),
encPassword: text("enc_password").notNull(),
encSudoPassword: text("enc_sudo_password"),
aptProxyMode: text("apt_proxy_mode").notNull().default("direct"),
aptProxyUrl: text("apt_proxy_url"),
status: text("status").notNull().default("unknown"),
lastCheckedAt: text("last_checked_at"),
lastSeenAt: text("last_seen_at"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at"),
deletedAt: text("deleted_at"),
});
export const snapshots = sqliteTable("snapshots", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull().default("apt_update_analyze"),
schemaVersion: integer("schema_version").notNull().default(1),
checkedAt: text("checked_at").notNull(),
status: text("status").notNull(),
payloadJson: text("payload_json").notNull(),
importantJson: text("important_json"),
rawLogPath: text("raw_log_path"),
rawArtifactId: text("raw_artifact_id"),
sourceJobId: text("source_job_id"),
});
export const executions = sqliteTable("executions", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
action: text("action").notNull(),
mode: text("mode").notNull().default("manual"),
schemaVersion: integer("schema_version").notNull().default(1),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at"),
status: text("status").notNull(),
requestId: text("request_id"),
jobId: text("job_id"),
resultJson: text("result_json"),
importantJson: text("important_json"),
reportPath: text("report_path"),
rawLogPath: text("raw_log_path"),
reportId: text("report_id"),
exitCode: integer("exit_code"),
errorKind: text("error_kind"),
errorMessage: text("error_message"),
});
export const machineState = sqliteTable("machine_state", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
status: text("status").notNull(),
aptStatus: text("apt_status"),
aptUpdatesCount: integer("apt_updates_count").notNull().default(0),
aptRebootRequired: integer("apt_reboot_required").notNull().default(0),
aptLastAnalyzeAt: text("apt_last_analyze_at"),
dockerStatus: text("docker_status"),
dockerInstalled: integer("docker_installed").notNull().default(0),
dockerStacksCount: integer("docker_stacks_count").notNull().default(0),
dockerUpdatesCount: integer("docker_updates_count").notNull().default(0),
dockerPruneAvailable: integer("docker_prune_available").notNull().default(0),
postInstallStatus: text("post_install_status"),
metricsLastCollectedAt: text("metrics_last_collected_at"),
cpuLoad1: real("cpu_load1"),
memoryUsedPercent: real("memory_used_percent"),
rootUsedPercent: real("root_used_percent"),
diskWarningsCount: integer("disk_warnings_count").notNull().default(0),
hardwareWarningsCount: integer("hardware_warnings_count").notNull().default(0),
runningJobId: text("running_job_id"),
lastErrorKind: text("last_error_kind"),
lastErrorMessage: text("last_error_message"),
updatedAt: text("updated_at").notNull(),
});
export const machineHardware = sqliteTable("machine_hardware", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
probeSnapshotId: text("probe_snapshot_id"),
cpuModel: text("cpu_model"),
cpuCores: integer("cpu_cores"),
memoryBytes: integer("memory_bytes"),
gpusJson: text("gpus_json"),
disksJson: text("disks_json"),
networkJson: text("network_json"),
firmwareJson: text("firmware_json"),
driverJson: text("driver_json"),
warningsJson: text("warnings_json"),
updatedAt: text("updated_at").notNull(),
});
export const machineMetricsLatest = sqliteTable("machine_metrics_latest", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
snapshotId: text("snapshot_id"),
collectedAt: text("collected_at").notNull(),
cpuLoad1: real("cpu_load1"),
cpuLoad5: real("cpu_load5"),
cpuCores: integer("cpu_cores"),
memoryTotalBytes: integer("memory_total_bytes"),
memoryUsedBytes: integer("memory_used_bytes"),
memoryAvailableBytes: integer("memory_available_bytes"),
memoryUsedPercent: real("memory_used_percent"),
filesystemsJson: text("filesystems_json"),
rootUsedPercent: real("root_used_percent"),
warningsJson: text("warnings_json"),
});
export const machineEvents = sqliteTable("machine_events", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
eventType: text("event_type").notNull(),
severity: text("severity").notNull(), // info | warning | error
createdAt: text("created_at").notNull(),
actorType: text("actor_type"), // user | system | schedule | hermes
actorId: text("actor_id"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
jobId: text("job_id"),
message: text("message"),
payloadJson: text("payload_json"),
});
export const importantMessages = sqliteTable("important_messages", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
source: text("source").notNull(), // apt | docker | post_install | ssh | system
category: text("category").notNull(), // error | warning | future_major_change | ...
severity: text("severity").notNull(),
packageName: text("package_name"),
component: text("component"),
message: text("message").notNull(),
rawLineRef: text("raw_line_ref"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
acknowledged: integer("acknowledged").notNull().default(0),
acknowledgedAt: text("acknowledged_at"),
acknowledgedBy: text("acknowledged_by"),
payloadJson: text("payload_json"),
});
export const reports = sqliteTable("reports", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
executionId: text("execution_id"),
kind: text("kind").notNull(), // machine | global | cleanup | hermes
title: text("title").notNull(),
path: text("path").notNull(),
createdAt: text("created_at").notNull(),
pinned: integer("pinned").notNull().default(0),
summaryJson: text("summary_json"),
});
export const rawArtifacts = sqliteTable("raw_artifacts", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull(), // raw_log | rendered_template | export | screenshot
path: text("path").notNull(),
bytes: integer("bytes"),
sha256: text("sha256"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at"),
pinned: integer("pinned").notNull().default(0),
redacted: integer("redacted").notNull().default(1),
retentionPolicy: text("retention_policy"), // default | failed | pinned | short
deletedAt: text("deleted_at"),
deleteReason: text("delete_reason"),
metadataJson: text("metadata_json"),
});
// --- Préexistant (WIP api_clients) : NE PAS supprimer ---
export const apiClients = sqliteTable(
"api_clients",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
tokenPrefix: text("token_prefix").notNull(),
tokenHash: text("token_hash").notNull(),
scopesJson: text("scopes_json").notNull(),
createdAt: text("created_at").notNull(),
lastUsedAt: text("last_used_at"),
revokedAt: text("revoked_at"),
},
(table) => ({
tokenHashIdx: uniqueIndex("api_clients_token_hash_unique").on(table.tokenHash),
}),
);
```
> Avant d'écrire : relire l'état RÉEL de `server/db/schema.ts` (`rtk read server/db/schema.ts`). Si `apiClients` y a évolué (colonnes différentes), reprendre sa définition à l'identique plutôt que celle ci-dessus, pour ne pas régresser le WIP. N'ajouter que les colonnes étendues + les 7 nouvelles tables.
- [ ] **Step 2 : Générer la migration**
Run: `rtk pnpm db:generate`
Expected: un nouveau fichier `server/db/migrations/0002_*.sql` est créé (ALTER TABLE machines/snapshots/executions + CREATE TABLE des 7 nouvelles tables). La migration `0001_api_clients.sql` reste intacte. Aucune erreur drizzle-kit, aucun DROP de `api_clients`.
- [ ] **Step 3 : Écrire le test de migration `server/db/schema.test.ts`**
```ts
// server/db/schema.test.ts
import { describe, it, expect } from "vitest";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
function freshMigratedDb() {
const sqlite = new Database(":memory:");
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "./server/db/migrations" });
return sqlite;
}
function tableNames(sqlite: Database.Database): string[] {
return sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r: any) => r.name);
}
function columnNames(sqlite: Database.Database, table: string): string[] {
return sqlite.prepare(`PRAGMA table_info(${table})`).all().map((r: any) => r.name);
}
describe("schéma Phase 1", () => {
it("crée les tables socle", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
for (const t of [
"machines", "snapshots", "executions",
"machine_state", "machine_hardware", "machine_metrics_latest",
"machine_events", "important_messages", "reports", "raw_artifacts",
]) {
expect(tables, `table ${t}`).toContain(t);
}
});
it("ajoute les colonnes étendues sans casser l'existant", () => {
const sqlite = freshMigratedDb();
expect(columnNames(sqlite, "machines")).toEqual(
expect.arrayContaining(["machine_kind", "virtualization", "hardware_profile", "os_version", "updated_at"]),
);
expect(columnNames(sqlite, "snapshots")).toEqual(
expect.arrayContaining(["kind", "schema_version", "important_json"]),
);
expect(columnNames(sqlite, "executions")).toEqual(
expect.arrayContaining(["schema_version", "error_kind", "error_message", "exit_code"]),
);
// colonnes jalon 1 conservées
expect(columnNames(sqlite, "snapshots")).toContain("checked_at");
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
});
```
- [ ] **Step 4 : Lancer le test**
Run: `rtk pnpm vitest run server/db/schema.test.ts`
Expected: PASS (2 tests). Si une colonne attendue manque, corriger `schema.ts` et régénérer la migration (`rtk pnpm db:generate`) — ne pas modifier le test.
- [ ] **Step 5 : Vérifier la compilation + non-régression**
Run: `rtk pnpm check && rtk pnpm test`
Expected: 0 erreur TS ; toute la suite verte (les tests existants ne doivent pas casser).
- [ ] **Step 6 : (pas de commit — vérification seulement)**
Vérifier `git status` : seuls `server/db/schema.ts`, `server/db/migrations/0001_*.sql`, `server/db/schema.test.ts` ajoutés à la liste des modifs. Ne PAS committer.
---
## Task 2 : Service `machineState` (dérivation + upsert + events)
**Files:**
- Create: `server/services/machineState.ts`, `server/services/machineState.test.ts`
- [ ] **Step 1 : Écrire le test (échec attendu)**
```ts
// server/services/machineState.test.ts
import { describe, it, expect } from "vitest";
import { deriveAptState } from "./machineState.js";
import type { UpdateSnapshot } from "@shared/types.js";
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
checkedAt: "2026-06-05T10:00:00Z", status: "updates_available",
apt: { enabled: true, count: 3, rebootRequired: true, packages: [] },
};
describe("deriveAptState", () => {
it("dérive le bloc APT de machine_state depuis un snapshot", () => {
expect(deriveAptState(snap)).toEqual({
status: "updates_available",
aptStatus: "updates_available",
aptUpdatesCount: 3,
aptRebootRequired: 1,
aptLastAnalyzeAt: "2026-06-05T10:00:00Z",
});
});
it("met rebootRequired à 0 quand absent", () => {
const s = { ...snap, status: "ok" as const, apt: { ...snap.apt, count: 0, rebootRequired: false } };
expect(deriveAptState(s)).toMatchObject({ aptUpdatesCount: 0, aptRebootRequired: 0, status: "ok" });
});
});
```
- [ ] **Step 2 : Lancer (échec)**
Run: `rtk pnpm vitest run server/services/machineState.test.ts`
Expected: FAIL — module introuvable.
- [ ] **Step 3 : Implémenter `server/services/machineState.ts`**
```ts
// server/services/machineState.ts
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import type { UpdateSnapshot } from "@shared/types.js";
export interface AptDerivedState {
status: string;
aptStatus: string;
aptUpdatesCount: number;
aptRebootRequired: number;
aptLastAnalyzeAt: string;
}
/** Dérive le bloc APT de l'état courant depuis un snapshot (fonction pure). */
export function deriveAptState(snapshot: UpdateSnapshot): AptDerivedState {
return {
status: snapshot.status,
aptStatus: snapshot.status,
aptUpdatesCount: snapshot.apt.count,
aptRebootRequired: snapshot.apt.rebootRequired ? 1 : 0,
aptLastAnalyzeAt: snapshot.checkedAt,
};
}
type MachineStateInsert = typeof schema.machineState.$inferInsert;
/** Insère ou met à jour les champs fournis de machine_state pour une machine. */
export function upsertMachineState(
machineId: string,
fields: Partial<Omit<MachineStateInsert, "machineId" | "updatedAt" | "status">> & { status: string },
): void {
const now = new Date().toISOString();
db.insert(schema.machineState)
.values({ machineId, updatedAt: now, ...fields })
.onConflictDoUpdate({
target: schema.machineState.machineId,
set: { ...fields, updatedAt: now },
})
.run();
}
/** Ajoute une ligne à la timeline machine_events. */
export function recordEvent(input: {
machineId: string;
eventType: string;
severity: "info" | "warning" | "error";
actorType?: string;
snapshotId?: string;
executionId?: string;
message?: string;
}): void {
db.insert(schema.machineEvents).values({
id: randomUUID(),
machineId: input.machineId,
eventType: input.eventType,
severity: input.severity,
createdAt: new Date().toISOString(),
actorType: input.actorType ?? "system",
snapshotId: input.snapshotId,
executionId: input.executionId,
message: input.message,
}).run();
}
/** Utilitaire interne réservé aux migrations/tests éventuels. */
export const _internal = { sql };
```
> Note : `_internal` exporte `sql` pour rester explicite sur l'import drizzle ; supprime-le si lint le signale inutilisé et retire l'import `sql` correspondant.
- [ ] **Step 4 : Lancer (succès)**
Run: `rtk pnpm vitest run server/services/machineState.test.ts`
Expected: PASS (2 tests). `deriveAptState` est pure et n'importe pas de DB au moment du test — mais le module importe `../db/client.js`. Si l'import de `db/client` fait échouer le test en environnement node (chargement better-sqlite3), refactorer en isolant la fonction pure dans le même fichier sans exécuter de requête à l'import (c'est déjà le cas : aucune requête n'est lancée à l'import). Le test n'appelle que `deriveAptState`.
- [ ] **Step 5 : Vérifier**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 3 : Peupler l'état + la timeline dans `refreshMachine`
**Files:**
- Modify: `server/services/refresh.ts`
- [ ] **Step 1 : Lire l'état actuel de `refresh.ts`**
Run: `rtk read server/services/refresh.ts`
Repère : la construction de `snapshot`, l'`insert` dans `schema.snapshots`, et l'`update` de `machines.status`.
- [ ] **Step 2 : Enrichir l'insertion du snapshot (kind/schema_version/important_json)**
Dans `refreshMachine`, remplacer l'insertion actuelle du snapshot par :
```ts
const snapshotId = randomUUID();
db.insert(schema.snapshots).values({
id: snapshotId,
machineId,
kind: "apt_update_analyze",
schemaVersion: 1,
checkedAt,
status,
payloadJson: JSON.stringify(snapshot),
importantJson: JSON.stringify(snapshot.rawHints?.logImportantLines ?? []),
}).run();
```
(Le `randomUUID` est déjà importé dans `refresh.ts`.)
- [ ] **Step 3 : Mettre à jour `machine_state` + event après le snapshot**
Ajouter, juste après l'`update` de `machines` (status/lastCheckedAt), et après avoir importé en tête de fichier
`import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";` :
```ts
upsertMachineState(machineId, deriveAptState(snapshot));
recordEvent({
machineId,
eventType: "apt_refresh",
severity: status === "error" ? "error" : "info",
snapshotId,
message: `Refresh APT : ${snapshot.apt.count} mise(s) à jour`,
});
```
- [ ] **Step 4 : Vérifier compilation + tests**
Run: `rtk pnpm check && rtk pnpm vitest run server/services/refresh.test.ts`
Expected: 0 erreur TS ; le test existant `extractSection` reste vert (il n'importe pas la DB grâce au mock en place).
- [ ] **Step 5 : (pas de commit)**
---
## Task 4 : Enrichir `runAction` (champs executions + state + event + reports + raw_artifacts)
**Files:**
- Modify: `server/services/execute.ts`
- [ ] **Step 1 : Lire l'état actuel de `execute.ts`**
Run: `rtk read server/services/execute.ts`
Repère : l'`insert` initial dans `executions`, le bloc d'archivage (`writeFileSync` du log + rapport), l'`update` final de `executions` et de `machines`.
- [ ] **Step 2 : Importer les helpers**
En tête de `execute.ts`, ajouter :
```ts
import { randomUUID } from "node:crypto"; // déjà présent — ne pas dupliquer
import { statSync } from "node:fs";
import { upsertMachineState, recordEvent } from "./machineState.js";
```
(Si `randomUUID` est déjà importé, n'ajouter que `statSync` et la ligne `machineState`.)
- [ ] **Step 3 : Mettre `running_job_id`/status dans machine_state au démarrage**
Juste après l'`update` initial de `machines` en `status: "running"` et l'`insert` de l'exécution (status `running`), ajouter :
```ts
upsertMachineState(machineId, { status: "running", runningJobId: executionId });
```
- [ ] **Step 4 : Enrichir l'`update` final de l'exécution**
Remplacer l'`update` final de `schema.executions` par (ajout `schemaVersion`, `importantJson`, `exitCode`, `errorKind`, `errorMessage`, `reportId`) :
```ts
const reportId = randomUUID();
const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw);
db.update(schema.executions).set({
finishedAt,
status,
schemaVersion: 1,
resultJson: JSON.stringify(result),
importantJson: JSON.stringify(result.importantLogLines),
reportPath,
rawLogPath,
reportId,
exitCode: exitMatch ? Number(exitMatch[1]) : null,
errorKind: status === "error" ? "execution_failed" : null,
errorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
}).where(eq(schema.executions.id, executionId)).run();
```
- [ ] **Step 5 : Insérer `reports` + `raw_artifacts` + state + event**
Juste après l'`update` final de `machines`, ajouter :
```ts
db.insert(schema.reports).values({
id: reportId,
machineId,
executionId,
kind: "machine",
title: `${m.name}${action}`,
path: reportPath,
createdAt: finishedAt,
}).run();
db.insert(schema.rawArtifacts).values({
id: randomUUID(),
machineId,
kind: "raw_log",
path: rawLogPath,
bytes: statSync(rawLogPath).size,
createdAt: finishedAt,
retentionPolicy: status === "error" ? "failed" : "default",
}).run();
upsertMachineState(machineId, {
status: status === "error" ? "error" : "unknown",
runningJobId: null,
lastErrorKind: status === "error" ? "execution_failed" : null,
lastErrorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
});
recordEvent({
machineId,
eventType: `action_${action}`,
severity: status === "error" ? "error" : status === "warning" ? "warning" : "info",
executionId,
message: `Action ${action} : ${status}`,
});
```
- [ ] **Step 6 : Vérifier compilation + tests**
Run: `rtk pnpm check && rtk pnpm test`
Expected: 0 erreur TS ; suite complète verte.
- [ ] **Step 7 : (pas de commit)**
---
## Task 5 : Vérification finale Phase 1
**Files:** aucun (vérification).
- [ ] **Step 1 : Suite + build**
Run: `rtk pnpm check && rtk pnpm test && rtk pnpm build`
Expected: 0 erreur TS ; tests verts (jalon 1 + schema migration + machineState + helpers existants) ; `dist/index.js` + `dist/client` produits.
- [ ] **Step 2 : Démarrage runtime + migration appliquée**
Run (clé jetable, DB jetable) :
```bash
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/phase1-check.db SU_REPORTS_DIR=./data/phase1-reports
node dist/index.js > ./data/phase1.log 2>&1 &
sleep 3
curl -s localhost:8787/health
sqlite3 ./data/phase1-check.db ".tables" 2>/dev/null || echo "(sqlite3 absent — vérifier via le test de migration)"
kill %1 2>/dev/null
rm -rf ./data/phase1-check.db* ./data/phase1-reports ./data/phase1.log
```
Expected: `{"ok":true}` et la liste des tables inclut `machine_state`, `machine_events`, `reports`, `raw_artifacts`, etc. (migration appliquée au boot via `runMigrations()`).
- [ ] **Step 3 : Synthèse à l'utilisateur**
Reporter : tables/colonnes ajoutées, `machine_state`/`machine_events`/`reports`/`raw_artifacts` peuplés lors des refresh/exécutions, non-régression confirmée. **Ne pas committer** (l'utilisateur gère les commits en fin de parcours).
---
## Self-Review (couverture tache1.9 §14 Phase 1)
- machine_state → Task 1 (table) + Task 2/3/4 (peuplement). ✓
- machine_kind/virtualization/hardware_profile dans machines → Task 1. ✓
- machine_hardware → Task 1 (table ; producteur = tâche 4, hors Phase 1). ✓
- machine_metrics_latest → Task 1 (table ; producteur = tâche 4). ✓
- machine_events → Task 1 + Task 3/4 (peuplement). ✓
- important_messages → Task 1 (table ; peuplement fin = tâche 5/7, l'`important_json` du snapshot/exécution est déjà capturé). ✓
- reports → Task 1 + Task 4 (peuplement depuis le rapport déjà écrit). ✓
- raw_artifacts → Task 1 + Task 4 (peuplement depuis le log déjà écrit). ✓
- snapshots.kind/schema_version/important_json → Task 1 + Task 3. ✓
- executions.schema_version/important_json/error_kind/error_message → Task 1 + Task 4. ✓
Décision assumée (rétro-compat) : `snapshots.checked_at` conservé (non renommé en `created_at`) pour ne pas casser `refresh.ts`. Tables sans producteur en Phase 1 (`machine_hardware`, `machine_metrics_latest`, `important_messages`) créées vides, alimentées aux tâches 4/5/7 — conforme au principe « migration progressive ».
Pas de placeholder. Noms cohérents : `deriveAptState`/`upsertMachineState`/`recordEvent` définis Task 2 et utilisés Tasks 3-4 ; `reportId` défini Task 4 Step 4 et réutilisé Step 5.
@@ -0,0 +1,269 @@
# Tâche 1.9 — Phase 2 (sécurité credentials) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development ou superpowers:executing-plans. Étapes en checkbox.
**Goal:** Isoler les secrets SSH dans une table dédiée `machine_credentials` (+ table `machine_host_keys`), de façon **non destructive** : nouvelle table, écriture dédiée, lecture prioritaire avec fallback sur `machines.enc_password`, et backfill des machines existantes.
**Architecture:** Ajout additif (Drizzle/SQLite, migration `0003`). `machines.enc_password`/`enc_sudo_password` sont CONSERVÉS (non droppés) comme fallback/legacy. Un service `credentials` écrit/lit `machine_credentials` ; `createMachine` y insère, `getCreds` lit `machine_credentials` puis retombe sur les colonnes `machines` si absent ; un backfill (idempotent) crée les lignes manquantes au démarrage. `machine_host_keys` est créée (schéma) pour la future vérification host key (pas de logique de vérif en Phase 2).
**Tech Stack:** Drizzle ORM, better-sqlite3, drizzle-kit, vitest.
---
## Invariants
- **Non destructif** : ne pas dropper `machines.enc_password`/`enc_sudo_password` (NOT NULL conservé). Pas de perte des machines réelles existantes.
- Secrets uniquement chiffrés (AES-256-GCM existant, `server/crypto/secrets.ts`). `machine_credentials` n'est JAMAIS exposée via l'API publique (la `MachineView` reste sans secret).
- Rétro-compatibilité : une machine sans ligne `machine_credentials` reste utilisable (fallback). Le backfill comble le manque.
- **Ne pas committer** (l'utilisateur gère les commits). Étapes « commit » remplacées par vérification.
- Tree partagé avec du WIP concurrent : ne toucher QUE `server/db/schema.ts`, migrations, `server/services/credentials.ts` (+test), `server/services/machines.ts`, et le point de backfill (`server/db/migrate.ts` ou `server/index.ts`). Relire chaque fichier avant édition (drift possible).
## File Structure
```
server/db/schema.ts # MODIF : +machine_credentials, +machine_host_keys
server/db/migrations/0003_*.sql # généré
server/services/credentials.ts # NOUVEAU : writeCredentials/readCreds/backfill
server/services/credentials.test.ts # NOUVEAU
server/services/machines.ts # MODIF : createMachine écrit credentials ; getCreds lit credentials+fallback
server/db/migrate.ts # MODIF : appeler backfill après migrate
```
---
## Task 1 : Tables `machine_credentials` + `machine_host_keys`
**Files:** Modify `server/db/schema.ts` ; generate migration ; extend `server/db/schema.test.ts`.
- [ ] **Step 1 : Relire le schéma réel**
Run: `rtk read server/db/schema.ts` (capter l'état courant, préserver tout l'existant : machines/snapshots/executions/apiClients + les 7 tables Phase 1).
- [ ] **Step 2 : Ajouter les deux tables** (à la fin de `schema.ts`, avant ou après `apiClients`, sans rien supprimer)
```ts
export const machineCredentials = sqliteTable("machine_credentials", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
authMethod: text("auth_method").notNull(), // password | ssh_key
encPassword: text("enc_password"),
encSudoPassword: text("enc_sudo_password"),
encPrivateKey: text("enc_private_key"),
encKeyPassphrase: text("enc_key_passphrase"),
sudoMode: text("sudo_mode").notNull(), // same_as_ssh | separate | none
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
lastTestAt: text("last_test_at"),
status: text("status"), // ok | error | unknown
});
export const machineHostKeys = sqliteTable("machine_host_keys", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull(),
port: integer("port").notNull(),
keyType: text("key_type"),
fingerprintSha256: text("fingerprint_sha256").notNull(),
publicKey: text("public_key"),
status: text("status").notNull(), // approved | changed | rejected | unknown
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
});
```
- [ ] **Step 3 : Générer la migration**
Run: `rtk pnpm db:generate`
Expected: `server/db/migrations/0003_*.sql` (CREATE TABLE machine_credentials + machine_host_keys uniquement). Vérifier qu'aucun DROP ni recréation de table existante n'apparaît (sinon corriger le schéma et régénérer).
- [ ] **Step 4 : Étendre `server/db/schema.test.ts`** — ajouter un test
```ts
it("crée les tables de credentials Phase 2", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
expect(tables).toEqual(expect.arrayContaining(["machine_credentials", "machine_host_keys"]));
// machines conserve ses colonnes secrets legacy (fallback)
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
```
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/db/schema.test.ts` → PASS. Puis `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 2 : Service `credentials` (write / read / backfill) — TDD
**Files:** Create `server/services/credentials.ts`, `server/services/credentials.test.ts`.
- [ ] **Step 1 : Test (échec attendu)**`server/services/credentials.test.ts`
```ts
import { describe, it, expect } from "vitest";
import { resolveCreds } from "./credentials.js";
describe("resolveCreds", () => {
it("préfère la ligne machine_credentials", () => {
const out = resolveCreds(
{ encPassword: "M_PWD", encSudoPassword: null }, // machines (legacy)
{ encPassword: "C_PWD", encSudoPassword: "C_SUDO" }, // machine_credentials
);
expect(out).toEqual({ encPassword: "C_PWD", encSudoPassword: "C_SUDO" });
});
it("retombe sur machines si pas de credentials", () => {
const out = resolveCreds({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" }, null);
expect(out).toEqual({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" });
});
});
```
- [ ] **Step 2 :** Run `rtk pnpm vitest run server/services/credentials.test.ts` → FAIL (module manquant).
- [ ] **Step 3 : Implémenter `server/services/credentials.ts`**
```ts
// server/services/credentials.ts
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
interface EncPair { encPassword: string | null; encSudoPassword: string | null; }
/** Résout la source des secrets : machine_credentials prioritaire, sinon legacy machines (fonction pure). */
export function resolveCreds(legacy: EncPair, creds: EncPair | null): EncPair {
if (creds && creds.encPassword) return { encPassword: creds.encPassword, encSudoPassword: creds.encSudoPassword };
return { encPassword: legacy.encPassword, encSudoPassword: legacy.encSudoPassword };
}
/** Écrit (insert/replace) la ligne machine_credentials pour une machine (secrets déjà chiffrés). */
export function writeCredentials(input: {
machineId: string;
encPassword: string | null;
encSudoPassword: string | null;
}): void {
const now = new Date().toISOString();
db.insert(schema.machineCredentials)
.values({
machineId: input.machineId,
authMethod: "password",
encPassword: input.encPassword,
encSudoPassword: input.encSudoPassword,
sudoMode: input.encSudoPassword ? "separate" : "same_as_ssh",
createdAt: now,
updatedAt: now,
status: "unknown",
})
.onConflictDoUpdate({
target: schema.machineCredentials.machineId,
set: { encPassword: input.encPassword, encSudoPassword: input.encSudoPassword, updatedAt: now },
})
.run();
}
/** Lit la ligne machine_credentials (ou null). */
export function readCredentials(machineId: string): EncPair | null {
const row = db.select().from(schema.machineCredentials)
.where(eq(schema.machineCredentials.machineId, machineId)).get();
return row ? { encPassword: row.encPassword, encSudoPassword: row.encSudoPassword } : null;
}
/** Backfill idempotent : crée une ligne machine_credentials pour chaque machine qui n'en a pas. */
export function backfillCredentials(): number {
const machines = db.select().from(schema.machines).all();
let created = 0;
for (const m of machines) {
if (readCredentials(m.id)) continue;
writeCredentials({ machineId: m.id, encPassword: m.encPassword, encSudoPassword: m.encSudoPassword });
created++;
}
return created;
}
```
- [ ] **Step 4 :** Run `rtk pnpm vitest run server/services/credentials.test.ts` → PASS (2). (Le test n'appelle que `resolveCreds`, pur ; pas de DB.)
- [ ] **Step 5 : (pas de commit)**
---
## Task 3 : Brancher dans `machines.ts` + backfill au démarrage
**Files:** Modify `server/services/machines.ts`, `server/db/migrate.ts`.
- [ ] **Step 1 : Relire `server/services/machines.ts`** (état réel : `getCreds`, `createMachine`).
- [ ] **Step 2 : `createMachine` écrit aussi machine_credentials**
Importer en tête : `import { writeCredentials, readCredentials, resolveCreds } from "./credentials.js";`
Après l'`insert` de la ligne `machines` (avant `return toView(row)`), ajouter :
```ts
writeCredentials({ machineId: id, encPassword: row.encPassword, encSudoPassword: row.encSudoPassword });
```
(On conserve aussi l'écriture dans `machines.enc_password` — non destructif.)
- [ ] **Step 3 : `getCreds` lit machine_credentials en priorité**
Remplacer le corps de `getCreds` par :
```ts
export function getCreds(m: MachineRow): SshCreds {
const key = env.requireMasterKey();
const { encPassword, encSudoPassword } = resolveCreds(
{ encPassword: m.encPassword, encSudoPassword: m.encSudoPassword },
readCredentials(m.id),
);
if (!encPassword) throw new Error("Aucun secret pour cette machine");
return {
hostname: m.hostname,
port: m.port,
username: m.username,
password: decryptSecret(encPassword, key),
sudoPassword: encSudoPassword ? decryptSecret(encSudoPassword, key) : null,
};
}
```
- [ ] **Step 4 : Backfill au démarrage** — dans `server/db/migrate.ts`, après `runMigrations()`, exposer et appeler le backfill. Modifier `runMigrations` pour enchaîner :
```ts
// server/db/migrate.ts
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./client.js";
import { backfillCredentials } from "../services/credentials.js";
export function runMigrations(): void {
migrate(db, { migrationsFolder: "./server/db/migrations" });
const n = backfillCredentials();
if (n > 0) console.log(`[migrate] backfill credentials: ${n} machine(s)`);
}
```
- [ ] **Step 5 :** Run `rtk pnpm check && rtk pnpm test` → 0 erreur TS ; tests verts (48 attendus : +2 credentials). Si un test hors périmètre (WIP concurrent) casse, le signaler sans corriger.
- [ ] **Step 6 : (pas de commit)**
---
## Task 4 : Vérification finale Phase 2
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert + `dist` produit.
- [ ] **Step 2 : Boot + backfill + tables**
```bash
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/p2.db SU_REPORTS_DIR=./data/p2-reports
node dist/index.js > ./data/p2.log 2>&1 &
sleep 3
curl -s localhost:8787/health
node -e "const D=require('better-sqlite3');const db=new D('./data/p2.db');console.log(db.prepare(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'machine_%'\").all().map(r=>r.name).join(', '));"
kill %1 2>/dev/null
rm -rf ./data/p2.db* ./data/p2-reports ./data/p2.log
```
Expected: `{"ok":true}` ; tables incluent `machine_credentials`, `machine_host_keys`. (Backfill = 0 sur DB neuve, normal.)
- [ ] **Step 3 :** Reporter à l'utilisateur (tables ajoutées, dual-read/backfill, non-régression). **Ne pas committer.**
---
## Self-Review (couverture tache1.9 §14 Phase 2)
- créer `machine_credentials` → Task 1. ✓
- migrer `enc_password`/`enc_sudo_password` → approche non destructive : dual-write + backfill + lecture prioritaire (Tasks 2-3). Les colonnes legacy restent comme fallback (drop = phase ultérieure de nettoyage). ✓
- créer `machine_host_keys` → Task 1 (schéma ; vérification host key = logique ultérieure). ✓
- audit événements secrets → léger : non inclus en Phase 2 (le `recordEvent` Phase 1 existe ; l'audit systématique des déchiffrements relève de tâche 7 sécurité). Noté comme suite.
Décision assumée : non destructif (pas de DROP des colonnes secrets de `machines`) pour protéger les machines réelles existantes. Noms cohérents : `resolveCreds`/`writeCredentials`/`readCredentials`/`backfillCredentials` définis Task 2, utilisés Task 3.
@@ -0,0 +1,408 @@
# Tâche 2 — SJ-0 (socle : types + réduction + résolution de profil) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Poser le socle de la tâche 2, **purement additif** : étendre `shared/types.ts` (unions élargies + blocs optionnels, rétro-compatibles), enrichir le réducteur de lignes (préfixes Docker), et ajouter `resolveTemplate(action, osFamily)` avec fallback `base`. Aucun changement de wiring (refresh/execute inchangés).
**Architecture:** Extensions additives. Référence design : `docs/design/tache2/40-contrats-json.md` (types), `60-profils-os-machine.md` (résolution), `99-couverture-gate.md`. Tous les ajouts sont optionnels/élargis ⇒ un `UpdateSnapshot`/`ExecutionResult` du jalon 1 reste strictement valide (vérifié par `tsc`).
**Tech Stack:** TypeScript, vitest.
---
## Invariants
- **Rétro-compat stricte** : ne rien retirer/renommer. Préserver `MachineStatus`, `MachineView`, `ServerCapabilities` (WIP) et tout autre contenu actuel de `shared/types.ts`.
- **Aucun changement de comportement** : on n'altère PAS `refresh.ts`/`execute.ts` en SJ-0 (la bascule du refresh sur les nouveaux templates = SJ-1).
- Réducteur : **garder `reduceAptLines`** (imports existants dans refresh/execute) ; ajouter les préfixes Docker et un alias `reduceLines`. **Ne PAS renommer le fichier** `aptReduce.ts` (éviter de toucher les imports de refresh/execute — churn/concurrence).
- Tree partagé / WIP concurrent : ne toucher QUE `shared/types.ts`, `server/templates/aptReduce.ts` (+test), `server/templates/render.ts` (+ test resolveTemplate), et les fichiers de test. **Ne pas committer.**
## File Structure
```
shared/types.ts # MODIF : unions élargies + interfaces + champs optionnels
shared/types.test.ts # NOUVEAU : verrouille la rétro-compat (compile + runtime léger)
server/templates/aptReduce.ts # MODIF : préfixes Docker + alias reduceLines
server/templates/aptReduce.test.ts # MODIF : +cas Docker
server/templates/render.ts # MODIF : +resolveTemplate
server/templates/resolveTemplate.test.ts # NOUVEAU
```
---
## Task 1 : Étendre `shared/types.ts`
**Files:** Modify `shared/types.ts` ; Create `shared/types.test.ts`.
- [ ] **Step 1 : Relire le fichier réel** (`rtk read shared/types.ts`) pour repérer le contenu à préserver (`MachineStatus`, `MachineView`, `ServerCapabilities`, etc.).
- [ ] **Step 2 : Appliquer les extensions** (élargir les unions existantes, remplacer `AptPackage`/`UpdateSnapshot`/`ExecutionResult` par les versions étendues, AJOUTER les nouvelles interfaces). Ne pas supprimer l'existant. Contenu cible (depuis `docs/design/tache2/40-contrats-json.md`) :
```ts
export type OsFamily = "debian" | "ubuntu" | "proxmox" | "raspbian" | "unknown";
export type MachineKind =
| "physical" | "vm" | "proxmox_host" | "lxc"
| "raspberry_pi" | "workstation" | "unknown";
export type AptProxyMode = "direct" | "runtime" | "persistent";
export type ActionType =
| "apt_full_upgrade" | "reboot"
| "apt_update_analyze" | "apt_upgrade" | "apt_dist_upgrade"
| "apt_autoremove" | "apt_clean" | "reboot_verified"
| "docker_scan" | "docker_inspect_current" | "docker_pull_check"
| "docker_compose_apply" | "docker_prune_images" | "docker_compose_down"
| "machine_probe" | "post_install";
export type SnapshotStatus = "ok" | "updates_available" | "warning" | "error";
// ExecutionStatus, MachineStatus : INCHANGÉS (préserver l'existant)
export interface AptPackage {
name: string;
currentVersion: string | null;
targetVersion: string;
origin: string | null;
arch?: string;
operation?: "upgrade" | "install" | "remove" | "hold";
severityHint?: "normal" | "security";
}
export interface AptSnapshotDetail {
enabled: boolean;
count: number;
rebootRequired: boolean;
packages: AptPackage[];
status?: SnapshotStatus;
upgradeCount?: number;
distUpgradeCount?: number;
installed?: AptPackage[];
removed?: AptPackage[];
held?: string[];
rebootPkgs?: string[];
}
export interface DockerSnapshotService {
serviceName: string;
image: string;
currentImageId?: string | null;
currentDigest?: string | null;
candidateImageId?: string | null;
candidateDigest?: string | null;
currentVersion?: string | null;
candidateVersion?: string | null;
sourceUrl?: string | null;
status?: "up_to_date" | "updates_available" | "warning" | "error";
}
export interface DockerSnapshotStack {
name: string;
workingDir: string;
composeFiles: string[];
projectName?: string | null;
status: "candidate" | "enabled" | "ignored" | "error";
detectedBy?: "root_scan" | "label" | "manual";
services: DockerSnapshotService[];
}
export interface DockerSnapshot {
enabled: boolean;
installed: boolean;
count: number;
declaredRoots?: string[];
stacks: DockerSnapshotStack[];
status?: SnapshotStatus;
}
export interface SnapshotError {
source: "apt" | "docker" | "post_install" | "ssh" | "system";
kind: string;
severity: "info" | "warning" | "error";
message: string;
remediation?: string;
importantLines?: string[];
}
export interface UpdateSnapshot {
machineId: string;
hostname: string;
os: { family: OsFamily; version: string };
checkedAt: string;
status: MachineStatus;
apt: AptSnapshotDetail;
schemaVersion?: number;
kind?: "apt_update_analyze" | "docker_scan" | "reboot_check" | "combined";
machineKind?: MachineKind;
docker?: DockerSnapshot;
errors?: SnapshotError[];
rawHints?: { logImportantLines: string[] };
}
export interface AptChange {
name: string;
arch?: string;
fromVersion: string | null;
toVersion: string | null;
operation: "upgraded" | "installed" | "removed" | "unchanged";
origin?: string | null;
}
export interface AptExecutionResult {
planned: AptPackage[];
applied: AptChange[];
installed: AptChange[];
removed: AptChange[];
held: string[];
errors?: SnapshotError[];
rebootRequiredAfterRun: boolean;
}
export interface DockerImageChange {
stack: string;
serviceName?: string;
imageRef?: string;
fromImageId?: string | null;
toImageId?: string | null;
fromDigest?: string | null;
toDigest?: string | null;
operation: "pulled" | "recreated" | "pruned";
}
export interface DockerExecutionResult {
pull?: { changes: DockerImageChange[]; errors?: SnapshotError[] };
up?: { recreated: string[]; running: string[]; exited: string[]; errors?: SnapshotError[] };
prune?: { imagesDeleted: string[]; bytesReclaimed: number; errors?: SnapshotError[] };
errors?: SnapshotError[];
}
export interface RebootResult {
beforeBootId: string | null;
afterBootId: string | null;
requestedAt: string;
sshWentDownAt: string | null;
sshCameBackAt: string | null;
waitedSeconds: number;
status: "ok" | "reboot_command_failed" | "ssh_never_went_down"
| "machine_did_not_return" | "boot_id_unchanged" | "timeout";
lastRebootDurationSeconds?: number;
nextRecommendedWaitSeconds?: number;
errors?: SnapshotError[];
}
export interface PostInstallResult {
profilesRun: string[];
variablesUsed: Record<string, string | number | boolean>;
filesModified: string[];
packagesInstalled: string[];
servicesEnabled: string[];
rebootsRequested: boolean;
networkChange?: { oldEndpoint: string | null; newEndpoint: string | null; reconnectHost: string | null };
errors?: SnapshotError[];
}
export interface ExecutionResult {
executionId: string;
machineId: string;
startedAt: string;
finishedAt: string;
mode: "manual" | "scheduled" | "hermes_requested";
action: ActionType;
status: ExecutionStatus;
rebootRequiredAfterRun: boolean;
importantLogLines: string[];
rawLogRef: string;
reportRef: string;
schemaVersion?: number;
apt?: AptExecutionResult;
docker?: DockerExecutionResult;
reboot?: RebootResult;
postInstall?: PostInstallResult;
errors?: SnapshotError[];
}
```
> Préserver `MachineStatus`, `MachineView`, `ServerCapabilities` et tout autre contenu présent. Le bloc `apt` de `UpdateSnapshot` reste **requis** (forme jalon 1) ; `mode` de `ExecutionResult` était le littéral `"manual"` → l'union l'inclut.
- [ ] **Step 3 : Test de rétro-compat `shared/types.test.ts`**
```ts
import { describe, it, expect } from "vitest";
import type { UpdateSnapshot, ExecutionResult } from "./types.js";
describe("rétro-compatibilité des contrats", () => {
it("un snapshot jalon 1 (sans blocs optionnels) reste valide", () => {
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
checkedAt: "2026-06-05T10:00:00Z", status: "ok",
apt: { enabled: true, count: 0, rebootRequired: false, packages: [] },
};
expect(snap.apt.count).toBe(0);
});
it("une exécution jalon 1 (mode manual, sans blocs) reste valide", () => {
const exec: ExecutionResult = {
executionId: "e1", machineId: "m1", startedAt: "a", finishedAt: "b",
mode: "manual", action: "apt_full_upgrade", status: "ok",
rebootRequiredAfterRun: false, importantLogLines: [], rawLogRef: "r", reportRef: "rr",
};
expect(exec.action).toBe("apt_full_upgrade");
});
it("accepte les nouveaux blocs optionnels", () => {
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "proxmox", version: "8" },
checkedAt: "t", status: "updates_available",
apt: { enabled: true, count: 1, rebootRequired: false, packages: [], status: "updates_available" },
schemaVersion: 1, kind: "apt_update_analyze", machineKind: "proxmox_host",
docker: { enabled: false, installed: false, count: 0, stacks: [] },
errors: [],
};
expect(snap.docker?.installed).toBe(false);
});
});
```
- [ ] **Step 4 :** Run `rtk pnpm vitest run shared/types.test.ts` → PASS (3). Puis `rtk pnpm check`**0 erreur** (c'est le vrai test de rétro-compat : si un consommateur existant casse à cause d'un retrait/renommage, tsc le révèle). Si `check` signale une erreur dans un fichier consommateur (`refresh.ts`/`execute.ts`/`machines.ts`/WIP) causée par TON changement de types, corrige le type (rends additif) — ne casse pas les consommateurs.
- [ ] **Step 5 : (pas de commit)**
---
## Task 2 : Réducteur enrichi (préfixes Docker)
**Files:** Modify `server/templates/aptReduce.ts`, `server/templates/aptReduce.test.ts`.
- [ ] **Step 1 : Relire `server/templates/aptReduce.ts`** (état réel).
- [ ] **Step 2 : Ajouter un cas Docker au test `aptReduce.test.ts`**
```ts
it("garde aussi les lignes Docker utiles", () => {
const raw = [
"Pulling jellyfin ...",
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
"Recreating jellyfin ...",
"Started jellyfin",
"blabla inutile",
"Total reclaimed space: 1.2GB",
].join("\n");
expect(reduceLines(raw)).toEqual([
"Pulling jellyfin ...",
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
"Recreating jellyfin ...",
"Started jellyfin",
"Total reclaimed space: 1.2GB",
]);
});
```
Ajouter `reduceLines` à l'import existant : `import { reduceAptLines, reduceLines } from "./aptReduce.js";`
- [ ] **Step 3 : Lancer (échec attendu)**`rtk pnpm vitest run server/templates/aptReduce.test.ts` → FAIL (`reduceLines` introuvable / lignes Docker non gardées).
- [ ] **Step 4 : Étendre `server/templates/aptReduce.ts`**
```ts
// server/templates/aptReduce.ts
const PREFIXES = [
// APT / dpkg (jalon 1)
"Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:",
// Docker (SJ-0)
"Pulling", "Digest", "Status", "Downloaded newer image", "Recreating", "Started", "Error",
];
const CONTAINS = [
"reboot-required", "REBOOT_REQUIRED",
"deleted", "Total reclaimed space",
];
/** Garde uniquement les lignes informatives (APT + Docker) d'une sortie brute. */
export function reduceLines(raw: string): string[] {
return raw
.split("\n")
.map((l) => l.trimEnd())
.filter((l) => PREFIXES.some((p) => l.startsWith(p)) || CONTAINS.some((c) => l.includes(c)));
}
/** Alias rétro-compatible (jalon 1) : même comportement, conserve les imports existants. */
export const reduceAptLines = reduceLines;
```
> Garder l'export `reduceAptLines` (utilisé par `refresh.ts`/`execute.ts`). `reduceLines` est le nouveau nom canonique.
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/templates/aptReduce.test.ts` → PASS (cas APT existants + nouveau cas Docker). `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 3 : `resolveTemplate(action, osFamily)`
**Files:** Modify `server/templates/render.ts` ; Create `server/templates/resolveTemplate.test.ts`.
- [ ] **Step 1 : Relire `server/templates/render.ts`** (état réel : `TEMPLATES_ROOT`, `renderTemplate`, `TemplateVars`).
- [ ] **Step 2 : Test `server/templates/resolveTemplate.test.ts`**
```ts
import { describe, it, expect } from "vitest";
import { resolveTemplate } from "./render.js";
describe("resolveTemplate", () => {
it("retombe sur apt/ quand aucun dossier OS spécifique n'existe (fonction exists fournie)", () => {
const noneExist = () => false;
expect(resolveTemplate("full-upgrade", "proxmox", noneExist)).toBe("apt/full-upgrade.sh.tpl");
expect(resolveTemplate("update-analyze", "debian", noneExist)).toBe("apt/update-analyze.sh.tpl");
});
it("choisit le template OS spécifique quand il existe", () => {
const proxmoxExists = (rel: string) => rel === "proxmox/full-upgrade.sh.tpl";
expect(resolveTemplate("full-upgrade", "proxmox", proxmoxExists)).toBe("proxmox/full-upgrade.sh.tpl");
});
it("unknown retombe toujours sur apt/", () => {
const all = () => true;
expect(resolveTemplate("clean", "unknown", all)).toBe("apt/clean.sh.tpl");
});
});
```
- [ ] **Step 3 : Lancer (échec)**`rtk pnpm vitest run server/templates/resolveTemplate.test.ts` → FAIL.
- [ ] **Step 4 : Ajouter `resolveTemplate` à `server/templates/render.ts`** (sans toucher `renderTemplate`/`TemplateVars` existants ; ajouter l'import `existsSync`) :
```ts
import { existsSync } from "node:fs";
// ... (TEMPLATES_ROOT, renderTemplate existants inchangés) ...
/** Existence par défaut d'un template relatif à templates/. */
function defaultExists(rel: string): boolean {
return existsSync(resolve(TEMPLATES_ROOT, rel));
}
/**
* Résout le chemin de template le plus spécifique pour (action, OS) :
* `<osFamily>/<action>.sh.tpl` s'il existe, sinon fallback base `apt/<action>.sh.tpl`.
* `exists` est injectable pour les tests.
*/
export function resolveTemplate(
action: string,
osFamily: string,
exists: (rel: string) => boolean = defaultExists,
): string {
const specific = `${osFamily}/${action}.sh.tpl`;
if (osFamily !== "unknown" && osFamily !== "apt" && exists(specific)) return specific;
return `apt/${action}.sh.tpl`;
}
```
> Note : `renderTemplate` accepte déjà un `relPath` (ex. `apt/full-upgrade.sh.tpl`), donc `renderTemplate(resolveTemplate(action, osFamily), vars)` fonctionnera en SJ-1 sans modifier `renderTemplate`.
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/templates/resolveTemplate.test.ts` → PASS (3). `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 4 : Vérification finale SJ-0
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build`
Expected: 0 erreur TS ; tous tests verts (49 Phase 2 + 3 types + 1 Docker reduce + 3 resolveTemplate ≈ 56) ; build OK.
- [ ] **Step 2 :** Reporter : types étendus rétro-compatibles (tsc vert = preuve), réducteur Docker, `resolveTemplate` prêt pour SJ-1. **Ne pas committer.**
---
## Self-Review (couverture SJ-0)
- Types étendus (unions + blocs optionnels) → Task 1, rétro-compat verrouillée par `tsc` + test. ✓
- Réducteur + préfixes Docker → Task 2 (`reduceLines` + alias `reduceAptLines` conservé). ✓
- `resolveTemplate(action, osFamily)` + fallback base → Task 3. ✓
- `schemaVersion` → présent dans `UpdateSnapshot`/`ExecutionResult` (optionnel). ✓
- Aucun wiring modifié (refresh/execute intacts) ⇒ non-régression jalon 1. ✓
Décisions assumées : fichier `aptReduce.ts` NON renommé (alias `reduceLines` ajouté) pour éviter de toucher les imports de refresh/execute (churn/concurrence) — le nom canonique `reduceLines` est exporté ; renommage physique reporté à un nettoyage ultérieur. `resolveTemplate` avec `exists` injectable pour testabilité des deux branches.
@@ -0,0 +1,349 @@
# Tâche 2 — SJ-1 (APT update/analyse enrichi) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Introduire `apt/update-analyze.sh.tpl` (refresh index + simulations `upgrade` et `dist-upgrade` + held + reboot-check, non destructif), son parsing enrichi (`AptSnapshotDetail` : upgrade/dist-upgrade/installed/removed/held/rebootPkgs + statut `ok|updates_available|warning|error`), et **basculer `refreshMachine` dessus** via `resolveTemplate`, en conservant `check.sh.tpl`.
**Architecture:** Additif. Référence design : `docs/design/tache2/10-templates-apt.md §4.1` (template) et `40-contrats-json.md §3` (`AptSnapshotDetail`). Le parsing est en TS (réutilise `parseAptSimulate` SJ-0/jalon 1) ; `buildAptSnapshotDetail` est une fonction pure testée sur fixtures. Le refresh bascule sur le nouveau template via `resolveTemplate("update-analyze", osFamily)` (fallback `apt/`). `check.sh.tpl` reste en place (non supprimé). Aucune rupture : `snapshot.apt` garde ses champs jalon 1 (enabled/count/rebootRequired/packages) + champs additifs.
**Tech Stack:** TypeScript, Mustache, vitest.
---
## Invariants
- `snapshot.apt` reste de forme jalon 1 (champs requis présents) ; on l'enrichit via les champs optionnels de `AptSnapshotDetail` (SJ-0).
- `MachineStatus` (union jalon 1, sans "warning") **inchangée** : le statut `warning` vit dans `snapshot.apt.status` ; `snapshot.status` (MachineStatus) mappe warning→`updates_available`.
- `check.sh.tpl` conservé. Wiring : seul `refreshMachine` bascule sur `update-analyze`.
- Tree partagé / WIP concurrent : ne toucher QUE `server/services/aptParse.ts` (+test/fixtures), `templates/apt/update-analyze.sh.tpl`, `server/services/refresh.ts`, `server/templates/render.test.ts` éventuel. **Ne pas committer.**
## File Structure
```
server/services/aptParse.ts # MODIF : +parseAptRemovals/parseHeld/parseRebootDetail/buildAptSnapshotDetail
server/services/aptParse.test.ts # MODIF : +tests build detail
server/services/__fixtures__/apt-update-analyze.txt # NOUVEAU : sortie complète du template
templates/apt/update-analyze.sh.tpl # NOUVEAU
server/services/refresh.ts # MODIF : bascule sur update-analyze + detail
```
---
## Task 1 : Parsing enrichi APT (TDD)
**Files:** Modify `server/services/aptParse.ts`, `server/services/aptParse.test.ts` ; Create `server/services/__fixtures__/apt-update-analyze.txt`.
- [ ] **Step 1 : Créer la fixture `server/services/__fixtures__/apt-update-analyze.txt`**
```
===SU:APT_UPDATE===
Hit:1 http://deb.debian.org/debian bookworm InRelease
Reading package lists...
===SU:APT_SIM_UPGRADE===
Reading package lists...
Building dependency tree...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_SIM_DISTUPGRADE===
Reading package lists...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Inst newdep (1.0.0 Debian:11.6/stable [all])
Remv oldpkg [3.2-1]
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_HELD===
frozenpkg
===SU:REBOOT===
REBOOT_REQUIRED=1
PKG=linux-image-amd64
===SU:EXIT=0===
```
- [ ] **Step 2 : Écrire le test (échec attendu)** — ajouter à `server/services/aptParse.test.ts`
```ts
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseAptRemovals, parseHeld, parseRebootDetail, buildAptSnapshotDetail } from "./aptParse.js";
const ua = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-update-analyze.txt", import.meta.url)), "utf8");
function section(raw: string, start: string, end: string): string {
const s = raw.indexOf(start); if (s === -1) return "";
const from = s + start.length; const e = raw.indexOf(end, from);
return raw.slice(from, e === -1 ? undefined : e).trim();
}
describe("parseAptRemovals", () => {
it("extrait les suppressions Remv", () => {
expect(parseAptRemovals("Remv oldpkg [3.2-1]\nInst x [1] (2 Y [amd64])"))
.toEqual([{ name: "oldpkg", currentVersion: "3.2-1" }]);
});
});
describe("parseHeld", () => {
it("liste les paquets retenus non vides", () => {
expect(parseHeld("frozenpkg\n\n other ")).toEqual(["frozenpkg", "other"]);
});
});
describe("parseRebootDetail", () => {
it("lit le flag et les paquets reboot", () => {
expect(parseRebootDetail("REBOOT_REQUIRED=1\nPKG=linux-image-amd64\nPKG=foo"))
.toEqual({ rebootRequired: true, pkgs: ["linux-image-amd64", "foo"] });
expect(parseRebootDetail("REBOOT_REQUIRED=0")).toEqual({ rebootRequired: false, pkgs: [] });
});
});
describe("buildAptSnapshotDetail", () => {
it("construit le détail enrichi depuis les sections", () => {
const detail = buildAptSnapshotDetail({
upgradeSim: section(ua, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
distUpgradeSim: section(ua, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
heldRaw: section(ua, "===SU:APT_HELD===", "===SU:REBOOT==="),
rebootRaw: section(ua, "===SU:REBOOT===", "===SU:EXIT"),
updateFailed: false,
});
expect(detail.enabled).toBe(true);
expect(detail.count).toBe(2); // 2 Inst en dist-upgrade
expect(detail.upgradeCount).toBe(1); // 1 Inst en upgrade
expect(detail.distUpgradeCount).toBe(2);
expect(detail.rebootRequired).toBe(true);
expect(detail.rebootPkgs).toEqual(["linux-image-amd64"]);
expect(detail.held).toEqual(["frozenpkg"]);
expect(detail.removed?.map((r) => r.name)).toEqual(["oldpkg"]);
expect(detail.installed?.map((p) => p.name)).toEqual(["newdep"]);
expect(detail.status).toBe("warning"); // car removed + held non vides
});
it("status=updates_available sans removed/held, error si update échoue", () => {
const ok = buildAptSnapshotDetail({ upgradeSim: "Inst a [1] (2 Y [amd64])", distUpgradeSim: "Inst a [1] (2 Y [amd64])", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: false });
expect(ok.status).toBe("updates_available");
const err = buildAptSnapshotDetail({ upgradeSim: "", distUpgradeSim: "", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: true });
expect(err.status).toBe("error");
});
});
```
- [ ] **Step 3 : Lancer (échec)**`rtk pnpm vitest run server/services/aptParse.test.ts` → FAIL.
- [ ] **Step 4 : Étendre `server/services/aptParse.ts`** (garder `parseAptSimulate`/`parseRebootRequired` existants ; ajouter) :
```ts
import type { AptPackage, AptSnapshotDetail, SnapshotStatus } from "@shared/types.js";
// ... (parseAptSimulate, parseRebootRequired existants conservés) ...
const REMV_RE = /^Remv (\S+)(?: \[([^\]]+)\])?/;
export function parseAptRemovals(raw: string): { name: string; currentVersion: string | null }[] {
const out: { name: string; currentVersion: string | null }[] = [];
for (const line of raw.split("\n")) {
const m = REMV_RE.exec(line.trimEnd());
if (m) out.push({ name: m[1]!, currentVersion: m[2] ?? null });
}
return out;
}
export function parseHeld(raw: string): string[] {
return raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
}
export function parseRebootDetail(raw: string): { rebootRequired: boolean; pkgs: string[] } {
const rebootRequired = /REBOOT_REQUIRED=1/.test(raw);
const pkgs: string[] = [];
for (const line of raw.split("\n")) {
const m = /^PKG=(.+)$/.exec(line.trim());
if (m) pkgs.push(m[1]!.trim());
}
return { rebootRequired, pkgs };
}
export interface AptSections {
upgradeSim: string;
distUpgradeSim: string;
heldRaw: string;
rebootRaw: string;
updateFailed: boolean;
}
export function buildAptSnapshotDetail(s: AptSections): AptSnapshotDetail {
const upgradePkgs = parseAptSimulate(s.upgradeSim);
const distPkgs = parseAptSimulate(s.distUpgradeSim);
const installed: AptPackage[] = distPkgs
.filter((p) => p.currentVersion === null)
.map((p) => ({ ...p, operation: "install" }));
const removed: AptPackage[] = parseAptRemovals(s.distUpgradeSim).map((r) => ({
name: r.name, currentVersion: r.currentVersion, targetVersion: "", origin: null, operation: "remove",
}));
const held = parseHeld(s.heldRaw);
const { rebootRequired, pkgs: rebootPkgs } = parseRebootDetail(s.rebootRaw);
let status: SnapshotStatus = "ok";
if (s.updateFailed) status = "error";
else if (removed.length > 0 || held.length > 0) status = "warning";
else if (distPkgs.length > 0) status = "updates_available";
return {
enabled: true,
count: distPkgs.length,
rebootRequired,
packages: distPkgs,
status,
upgradeCount: upgradePkgs.length,
distUpgradeCount: distPkgs.length,
installed,
removed,
held,
rebootPkgs,
};
}
```
- [ ] **Step 5 : Lancer (succès)**`rtk pnpm vitest run server/services/aptParse.test.ts` → PASS. `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 2 : Template `apt/update-analyze.sh.tpl`
**Files:** Create `templates/apt/update-analyze.sh.tpl`.
- [ ] **Step 1 : Créer le template** (depuis `10-templates-apt.md §4.1`)
```sh
#!/bin/sh
# Refresh index + simulations upgrade/dist-upgrade + held + reboot-check.
# Exécuté entier sous sudo par la couche SSH. Non destructif.
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:APT_UPDATE==="
apt-get update -qq 2>&1
UPD=$?
echo "===SU:APT_SIM_UPGRADE==="
apt-get -s -y upgrade 2>&1
echo "===SU:APT_SIM_DISTUPGRADE==="
apt-get -s -y dist-upgrade 2>&1
echo "===SU:APT_HELD==="
apt-mark showhold 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then
echo "REBOOT_REQUIRED=1"
[ -f /var/run/reboot-required.pkgs ] && sed 's/^/PKG=/' /var/run/reboot-required.pkgs
else
echo "REBOOT_REQUIRED=0"
fi
echo "===SU:EXIT=${UPD}==="
```
- [ ] **Step 2 : Vérifier le rendu**`rtk pnpm vitest run server/templates/render.test.ts` reste vert (le test existant porte sur `check.sh.tpl` ; pas de régression). Optionnellement ajouter un cas :
```ts
it("rend update-analyze.sh.tpl avec les sections attendues", () => {
const out = renderTemplate("apt/update-analyze.sh.tpl", { aptProxy: null });
expect(out).toContain("===SU:APT_SIM_DISTUPGRADE===");
expect(out).toContain("apt-mark showhold");
});
```
- [ ] **Step 3 : (pas de commit)**
---
## Task 3 : Basculer `refreshMachine` sur update-analyze
**Files:** Modify `server/services/refresh.ts`.
- [ ] **Step 1 : Relire `server/services/refresh.ts`** (état réel, incl. wiring Phase 1 machine_state/event).
- [ ] **Step 2 : Adapter les imports**
```ts
import { renderTemplate, resolveTemplate } from "../templates/render.js";
import {
parseAptSimulate, parseRebootRequired, // existants (peuvent rester importés)
buildAptSnapshotDetail,
} from "./aptParse.js";
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js";
```
- [ ] **Step 3 : Remplacer la construction du snapshot** dans `refreshMachine`. Remplacer le rendu + le parsing actuels (`check.sh.tpl`, `extractSection(...SIMULATE...)`) par :
```ts
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const script = renderTemplate(resolveTemplate("update-analyze", m.osFamily), { aptProxy: proxy });
let raw = "";
try {
const res = await runScriptSudo(getCreds(m), script, (c) => {
raw += c;
outputHub.publish(machineId, c);
});
raw = res.stdout;
} catch (err) {
db.update(schema.machines).set({ status: "error" }).where(eq(schema.machines.id, machineId)).run();
throw err;
}
const updateExit = /===SU:EXIT=(\d+)===/.exec(raw);
const detail: AptSnapshotDetail = buildAptSnapshotDetail({
upgradeSim: extractSection(raw, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
distUpgradeSim: extractSection(raw, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
heldRaw: extractSection(raw, "===SU:APT_HELD===", "===SU:REBOOT==="),
rebootRaw: extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
updateFailed: updateExit ? Number(updateExit[1]) !== 0 : false,
});
// MachineStatus n'a pas "warning" : warning => updates_available côté machine.
const status: MachineStatus =
detail.status === "error" ? "error" : detail.count > 0 || detail.status === "warning" ? "updates_available" : "ok";
const checkedAt = new Date().toISOString();
const snapshot: UpdateSnapshot = {
machineId,
hostname: m.hostname,
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: m.osVersion ?? "" },
checkedAt,
status,
apt: detail,
schemaVersion: 1,
kind: "apt_update_analyze",
rawHints: { logImportantLines: reduceAptLines(raw) },
};
```
> Conserver ensuite TOUT le bloc Phase 1 inchangé : insertion du snapshot (`kind`/`schemaVersion`/`importantJson`), update `machines`, `upsertMachineState(machineId, deriveAptState(snapshot))`, `recordEvent(...)`, `return snapshot;`. `deriveAptState` lit `snapshot.status`/`apt.count`/`apt.rebootRequired`/`checkedAt` — inchangé.
- [ ] **Step 4 : Vérifier**`rtk pnpm check && rtk pnpm vitest run server/services/refresh.test.ts server/services/aptParse.test.ts` → 0 erreur, tests verts (`extractSection` + parsing). Note : `check.sh.tpl` n'est plus référencé par le refresh mais reste sur disque (non supprimé), comme prévu.
- [ ] **Step 5 : (pas de commit)**
---
## Task 4 : Vérification finale SJ-1
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → 0 erreur, tous tests verts, build OK.
- [ ] **Step 2 : Boot smoke** (DB jetable) — confirmer que le serveur démarre (`/health`) avec le refresh branché sur le nouveau template (pas d'exécution SSH réelle ici) :
```bash
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/sj1.db SU_REPORTS_DIR=./data/sj1-reports
node dist/index.js > ./data/sj1.log 2>&1 &
sleep 3; curl -s localhost:8787/health; kill %1 2>/dev/null
rm -rf ./data/sj1.db* ./data/sj1-reports ./data/sj1.log
```
Expected: `{"ok":true}`.
- [ ] **Step 3 :** Reporter. Note pour l'utilisateur : la **vérif live** (refresh réel sur une machine Debian) confirmera le parsing des vraies sorties `apt-get -s`. **Ne pas committer.**
---
## Self-Review (couverture SJ-1)
- `apt/update-analyze.sh.tpl` (update + sim upgrade + sim dist-upgrade + held + reboot-check) → Task 2. ✓
- parsing des sections + `AptSnapshotDetail` enrichi (upgrade/dist/installed/removed/held/rebootPkgs + status) → Task 1 (TDD fixtures). ✓
- statut `ok|updates_available|warning|error``buildAptSnapshotDetail`. ✓
- bascule du refresh sur update-analyze (via `resolveTemplate`), `check.sh.tpl` conservé → Task 3. ✓
- non-régression : `snapshot.apt` garde la forme jalon 1 ; `MachineStatus` inchangée (warning→updates_available) ; machine_state/events Phase 1 préservés. ✓
Décision : `count = distUpgradeCount` (toutes les mises à jour disponibles, cohérent avec le jalon 1 qui comptait la simulation full-upgrade). `warning` (removed/held) exposé dans `apt.status`, mappé `updates_available` pour `machine.status`. Noms cohérents : `parseAptRemovals`/`parseHeld`/`parseRebootDetail`/`buildAptSnapshotDetail` définis Task 1, utilisés Task 3.
@@ -0,0 +1,325 @@
# Tâche 2 — SJ-2 (APT apply + diff dpkg réel) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Enrichir `apt/full-upgrade.sh.tpl` du snapshot dpkg avant/après, ajouter `apt/upgrade.sh.tpl`, `apt/autoremove.sh.tpl`, `apt/clean.sh.tpl`, calculer le **diff dpkg réel** (`AptExecutionResult` : applied/installed/removed), brancher les actions `apt_upgrade`/`apt_autoremove`/`apt_clean` (+ `apt_full_upgrade` enrichi) dans `runAction`, et ajouter un **timeout d'inactivité** optionnel à la couche SSH.
**Architecture:** Additif. Référence : `docs/design/tache2/10-templates-apt.md §4.2-4.4`, `40-contrats-json.md §4` (`AptExecutionResult`/`AptChange`), `50-erreurs.md` (`human_interaction_required`). Le diff dpkg est calculé en TS (`buildAptExecutionResult`, pure, TDD). `runScriptSudo` reçoit une option `inactivityTimeoutMs` (défaut 0 = désactivé ⇒ comportement jalon 1 inchangé) ; `runAction` la passe (600000) pour les actions APT. Les confirmations UI des suppressions relèvent de la tâche 3 ; SJ-2 expose `removed[]` dans le résultat.
**Tech Stack:** TypeScript, Mustache, ssh2, vitest.
---
## Invariants
- `apt_full_upgrade` et `reboot` (jalon 1) restent fonctionnels ; on **enrichit** sans casser le parsing exit/reboot existant de `execute.ts`.
- `runScriptSudo` : nouveau paramètre **optionnel** `inactivityTimeoutMs` (défaut 0 = pas de timeout) ⇒ `refreshMachine` et tout appelant existant **inchangés** de comportement.
- `ExecutionResult.apt` est optionnel (SJ-0) ⇒ une exécution sans diff reste valide.
- Tree partagé / WIP concurrent : ne toucher QUE `server/services/aptParse.ts` (+test/fixtures), `templates/apt/{full-upgrade,upgrade,autoremove,clean}.sh.tpl`, `server/ssh/client.ts`, `server/services/execute.ts`. **Ne pas committer.**
## File Structure
```
server/services/aptParse.ts # MODIF : +parseDpkgList/diffDpkg/buildAptExecutionResult
server/services/aptParse.test.ts # MODIF : +tests diff dpkg
templates/apt/full-upgrade.sh.tpl # MODIF : +DPKG_BEFORE/AFTER
templates/apt/upgrade.sh.tpl # NOUVEAU
templates/apt/autoremove.sh.tpl # NOUVEAU
templates/apt/clean.sh.tpl # NOUVEAU
server/ssh/client.ts # MODIF : +inactivityTimeoutMs (additif)
server/services/execute.ts # MODIF : actions APT + buildAptExecutionResult + timeout
```
---
## Task 1 : Diff dpkg (TDD)
**Files:** Modify `server/services/aptParse.ts`, `server/services/aptParse.test.ts`.
- [ ] **Step 1 : Test (échec attendu)** — ajouter à `aptParse.test.ts`
```ts
import { parseDpkgList, buildAptExecutionResult } from "./aptParse.js";
const BEFORE = "libc6\t2.31-13\tamd64\noldpkg\t3.2-1\tamd64\nstable\t1.0\tamd64";
const AFTER = "libc6\t2.31-14\tamd64\nnewpkg\t1.0.0\tall\nstable\t1.0\tamd64";
describe("parseDpkgList", () => {
it("indexe par package:arch", () => {
const m = parseDpkgList("libc6\t2.31-13\tamd64");
expect(m["libc6:amd64"]).toEqual({ version: "2.31-13", arch: "amd64" });
});
});
describe("buildAptExecutionResult", () => {
it("calcule le diff réel before/after", () => {
const r = buildAptExecutionResult(BEFORE, AFTER, "REBOOT_REQUIRED=1");
expect(r.applied.find((c) => c.name === "libc6")).toMatchObject({ operation: "upgraded", fromVersion: "2.31-13", toVersion: "2.31-14" });
expect(r.installed.map((c) => c.name)).toEqual(["newpkg"]);
expect(r.removed.map((c) => c.name)).toEqual(["oldpkg"]);
expect(r.applied.some((c) => c.name === "stable")).toBe(false); // unchanged exclu
expect(r.rebootRequiredAfterRun).toBe(true);
});
});
```
- [ ] **Step 2 : Lancer (échec)**`rtk pnpm vitest run server/services/aptParse.test.ts` → FAIL.
- [ ] **Step 3 : Étendre `server/services/aptParse.ts`**
```ts
import type { AptChange, AptExecutionResult } from "@shared/types.js";
export function parseDpkgList(raw: string): Record<string, { version: string; arch: string }> {
const out: Record<string, { version: string; arch: string }> = {};
for (const line of raw.split("\n")) {
const parts = line.split("\t");
if (parts.length < 3) continue;
const [name, version, arch] = [parts[0]!.trim(), parts[1]!.trim(), parts[2]!.trim()];
if (!name) continue;
out[`${name}:${arch}`] = { version, arch };
}
return out;
}
/** Diff dpkg réel before/after → AptExecutionResult (planned/held vides : portés par le snapshot). */
export function buildAptExecutionResult(beforeRaw: string, afterRaw: string, rebootRaw: string): AptExecutionResult {
const before = parseDpkgList(beforeRaw);
const after = parseDpkgList(afterRaw);
const applied: AptChange[] = [];
const installed: AptChange[] = [];
const removed: AptChange[] = [];
for (const key of Object.keys(after)) {
const [name] = key.split(":");
const a = after[key]!;
const b = before[key];
if (!b) {
const change: AptChange = { name: name!, arch: a.arch, fromVersion: null, toVersion: a.version, operation: "installed" };
installed.push(change); applied.push(change);
} else if (b.version !== a.version) {
applied.push({ name: name!, arch: a.arch, fromVersion: b.version, toVersion: a.version, operation: "upgraded" });
}
}
for (const key of Object.keys(before)) {
if (!after[key]) {
const [name] = key.split(":");
const b = before[key]!;
const change: AptChange = { name: name!, arch: b.arch, fromVersion: b.version, toVersion: null, operation: "removed" };
removed.push(change); applied.push(change);
}
}
return {
planned: [],
applied,
installed,
removed,
held: [],
rebootRequiredAfterRun: /REBOOT_REQUIRED=1/.test(rebootRaw),
};
}
```
- [ ] **Step 4 : Lancer (succès)**`rtk pnpm vitest run server/services/aptParse.test.ts` → PASS. `rtk pnpm check` → 0 erreur.
- [ ] **Step 5 : (pas de commit)**
---
## Task 2 : Templates APT (full-upgrade enrichi + upgrade/autoremove/clean)
**Files:** Modify `templates/apt/full-upgrade.sh.tpl` ; Create `upgrade.sh.tpl`, `autoremove.sh.tpl`, `clean.sh.tpl`.
- [ ] **Step 1 : Remplacer `templates/apt/full-upgrade.sh.tpl`** (ajoute DPKG_BEFORE/AFTER ; conserve REBOOT + EXIT que `execute.ts` parse déjà)
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_FULLUPGRADE==="
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold dist-upgrade 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
```
- [ ] **Step 2 : Créer `templates/apt/upgrade.sh.tpl`**
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_UPGRADE==="
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
```
- [ ] **Step 3 : Créer `templates/apt/autoremove.sh.tpl`**
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:APT_SIM_AUTOREMOVE==="
apt-get -s -y autoremove 2>&1
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_AUTOREMOVE==="
apt-get -y autoremove 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
```
- [ ] **Step 4 : Créer `templates/apt/clean.sh.tpl`**
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:APT_CLEAN==="
BEFORE=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
apt-get clean 2>&1
AFTER=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
echo "FREED_BYTES=$((BEFORE - AFTER))"
echo "===SU:EXIT=0==="
```
- [ ] **Step 5 :** `rtk pnpm vitest run server/templates/render.test.ts` reste vert. (pas de commit)
---
## Task 3 : Timeout d'inactivité SSH (additif)
**Files:** Modify `server/ssh/client.ts`.
- [ ] **Step 1 : Relire `server/ssh/client.ts`** (signatures `runScriptSudo`, `execStream`).
- [ ] **Step 2 : Ajouter un paramètre optionnel `inactivityTimeoutMs`** (défaut 0 = désactivé) à `runScriptSudo` et `execStream`. Dans `execStream`, armer un timer réinitialisé à chaque `data`/`stderr data` ; à expiration, `stream.close()`/`conn.end()` et `reject(new Error("human_interaction_required: aucune sortie depuis " + (ms/1000) + "s"))`.
```ts
export async function runScriptSudo(
creds: SshCreds,
script: string,
onData: (chunk: string) => void,
inactivityTimeoutMs = 0,
): Promise<RunResult> {
const conn = await connect(creds);
try {
const b64 = Buffer.from(script, "utf8").toString("base64");
const cmd = `sudo -S -p '' sh -c "$(printf '%s' '${b64}' | base64 -d)"`;
return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData, inactivityTimeoutMs);
} finally {
conn.end();
}
}
```
Dans `execStream(conn, command, stdinData, onData, inactivityTimeoutMs = 0)` : après obtention du `stream`,
```ts
let timer: NodeJS.Timeout | undefined;
const arm = () => {
if (!inactivityTimeoutMs) return;
clearTimeout(timer);
timer = setTimeout(() => {
stream.close();
reject(new Error(`human_interaction_required: aucune sortie depuis ${Math.round(inactivityTimeoutMs / 1000)}s`));
}, inactivityTimeoutMs);
};
arm();
```
Réinitialiser `arm()` dans les handlers `data` et `stderr data` ; `clearTimeout(timer)` dans `close`. (Garder le `runPlain` existant inchangé : il appelle `execStream` sans le 5e arg ⇒ timeout 0.)
- [ ] **Step 3 :** `rtk pnpm check` → 0 erreur. (Pas de test unitaire SSH ; vérif manuelle en live ultérieure.) (pas de commit)
---
## Task 4 : Brancher les actions APT dans `runAction`
**Files:** Modify `server/services/execute.ts`.
- [ ] **Step 1 : Relire `server/services/execute.ts`** (TEMPLATE_FOR, flux, update executions, blocs Phase 1 reports/artifacts/state/event).
- [ ] **Step 2 : Étendre `TEMPLATE_FOR`**
```ts
const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
apt_full_upgrade: "apt/full-upgrade.sh.tpl",
apt_upgrade: "apt/upgrade.sh.tpl",
apt_autoremove: "apt/autoremove.sh.tpl",
apt_clean: "apt/clean.sh.tpl",
reboot: "apt/reboot.sh.tpl",
};
```
(Adapter l'accès : `const rel = TEMPLATE_FOR[action]; if (!rel) throw new Error("Action sans template: " + action);`)
- [ ] **Step 3 : Passer le timeout d'inactivité** pour les actions APT (pas pour reboot) :
```ts
const inactivity = action === "reboot" ? 0 : 600000;
const res = await runScriptSudo(getCreds(m), script, (c) => { raw += c; outputHub.publish(machineId, c); }, inactivity);
```
- [ ] **Step 4 : Construire `result.apt` (diff dpkg) pour les actions APT applicatives.** Après calcul de `raw` et avant l'écriture du rapport, ajouter :
```ts
let aptResult: AptExecutionResult | undefined;
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
aptResult = buildAptExecutionResult(
extractSection(raw, "===SU:DPKG_BEFORE===", "==="), // jusqu'au prochain marqueur
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
);
}
```
> ⚠️ `extractSection(raw, "===SU:DPKG_BEFORE===", "===")` : le 2ᵉ marqueur générique `"==="` capture jusqu'au prochain `===SU:...===`. Vérifier que `extractSection` (dans `refresh.ts`) coupe bien au 1ᵉʳ `"==="` rencontré ; sinon, utiliser le marqueur réel suivant (`"===SU:APT_FULLUPGRADE==="` / `"===SU:APT_UPGRADE==="` / `"===SU:APT_AUTOREMOVE==="`). **Préférer** le marqueur explicite : détecter lequel est présent. Implémentation robuste :
```ts
const afterBeforeMarker =
raw.includes("===SU:APT_FULLUPGRADE===") ? "===SU:APT_FULLUPGRADE===" :
raw.includes("===SU:APT_UPGRADE===") ? "===SU:APT_UPGRADE===" :
"===SU:APT_AUTOREMOVE===";
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
aptResult = buildAptExecutionResult(
extractSection(raw, "===SU:DPKG_BEFORE===", afterBeforeMarker),
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
);
}
```
- [ ] **Step 5 : Attacher `aptResult` au `ExecutionResult`** : dans la construction de `result`, ajouter `...(aptResult ? { apt: aptResult } : {})`. Importer en tête : `import { parseRebootRequired, extractSection } ...` (extractSection vient de `./refresh.js` — déjà importé) et `import { buildAptExecutionResult } from "./aptParse.js";` ainsi que `import type { AptExecutionResult } from "@shared/types.js";`.
- [ ] **Step 6 : Vérifier**`rtk pnpm check && rtk pnpm test` → 0 erreur, tests verts. (pas de commit)
---
## Task 5 : Vérification finale SJ-2
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert.
- [ ] **Step 2 : Boot smoke** (DB jetable) → `/health` OK. Nettoyer.
- [ ] **Step 3 :** Reporter. Vérif live ultérieure : `apt_full_upgrade` réel sur Debian → vérifier `result.apt.applied` (diff dpkg réel) + détection removed/held + comportement du timeout. **Ne pas committer.**
---
## Self-Review (couverture SJ-2)
- Templates `upgrade`/`full-upgrade` enrichi/`autoremove`/`clean` → Task 2. ✓
- Capture `DPKG_BEFORE/AFTER` + diff réel (`AptExecutionResult`) → Task 1 + Task 4. ✓
- Timeout d'inactivité + `human_interaction_required` → Task 3 (additif, off par défaut) + Task 4 (600s pour APT). ✓
- Confirmations UI suppressions → hors périmètre (tâche 3) ; la donnée `removed[]` est exposée dans `result.apt`. ✓ (noté)
- Non-régression : `apt_full_upgrade`/`reboot` jalon 1 conservés ; `runScriptSudo` rétro-compatible (timeout 0 par défaut) ; `ExecutionResult.apt` optionnel ; blocs Phase 1 préservés. ✓
Décision : `planned`/`held` laissés vides dans `AptExecutionResult` (portés par le snapshot SJ-1, pas re-simulés à l'exécution). `extractSection` utilisé avec marqueur explicite pour `DPKG_BEFORE`. Noms cohérents : `parseDpkgList`/`buildAptExecutionResult` (Task 1) utilisés Task 4 ; `inactivityTimeoutMs` (Task 3) passé Task 4.
@@ -0,0 +1,252 @@
# Tâche 2 — SJ-3 (reboot vérifié) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Ajouter l'action `reboot_verified` : capture du `boot_id` avant reboot, orchestration backend (attente de la coupure SSH, reconnexion avec délai adaptatif, relecture du `boot_id`), production d'un `RebootResult` (`ok` seulement si la machine revient ET le `boot_id` a changé). L'action `reboot` (jalon 1) reste inchangée.
**Architecture:** Référence `docs/design/tache2/10-templates-apt.md §4.5` + `40-contrats-json.md §4` (`RebootResult`). Le template `apt/reboot.sh.tpl` est enrichi pour émettre `===SU:BOOT_ID_BEFORE===`. Un module `server/services/rebootVerify.ts` contient : `classifyReboot(...)` (fonction **pure**, TDD) + `verifyReboot(machineId)` (orchestration réseau : poll `runPlain` jusqu'à coupure puis retour). `execute.ts` route l'action `reboot_verified` vers cette orchestration. Délai adaptatif stocké dans `machine_state` (réutilise la table Phase 1).
**Tech Stack:** TypeScript, ssh2, vitest.
---
## Invariants
- `reboot` (jalon 1) **inchangé** (toujours via `apt/reboot.sh.tpl`, fire-and-forget). `reboot_verified` est une **nouvelle** action.
- `ExecutionResult.reboot` est optionnel (SJ-0) → rétro-compatible.
- Pas de blocage indéfini : timeouts bornés (détection coupure ≤ 60 s ; retour machine ≤ 600 s par défaut).
- Tree partagé / WIP concurrent : ne toucher QUE `templates/apt/reboot.sh.tpl`, `server/services/rebootVerify.ts` (+test), `server/services/execute.ts`. **Ne pas committer.**
## File Structure
```
templates/apt/reboot.sh.tpl # MODIF : +===SU:BOOT_ID_BEFORE===
server/services/rebootVerify.ts # NOUVEAU : classifyReboot (pure) + verifyReboot (orchestration)
server/services/rebootVerify.test.ts # NOUVEAU : tests classifyReboot
server/services/execute.ts # MODIF : route action reboot_verified
```
---
## Task 1 : Template `reboot.sh.tpl` (capture boot_id)
**Files:** Modify `templates/apt/reboot.sh.tpl`.
- [ ] **Step 1 : Remplacer `templates/apt/reboot.sh.tpl`** (ajoute BOOT_ID_BEFORE ; conserve REBOOT_NOW)
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:BOOT_ID_BEFORE==="
cat /proc/sys/kernel/random/boot_id 2>/dev/null
echo "===SU:REBOOT_NOW==="
# Reboot différé pour laisser le canal SSH se fermer proprement.
nohup sh -c 'sleep 2; reboot' >/dev/null 2>&1 &
echo "reboot planifié"
```
- [ ] **Step 2 : (pas de commit)**`templates/apt/reboot.sh.tpl` reste utilisé par l'action `reboot` (jalon 1) ET `reboot_verified`.
---
## Task 2 : `classifyReboot` (pure, TDD)
**Files:** Create `server/services/rebootVerify.ts`, `server/services/rebootVerify.test.ts`.
- [ ] **Step 1 : Test (échec attendu)**`server/services/rebootVerify.test.ts`
```ts
import { describe, it, expect } from "vitest";
import { classifyReboot, parseBootIdBefore } from "./rebootVerify.js";
describe("parseBootIdBefore", () => {
it("extrait le boot_id de la sortie du template", () => {
const raw = "===SU:BOOT_ID_BEFORE===\nabcd-1234\n===SU:REBOOT_NOW===\nreboot planifié";
expect(parseBootIdBefore(raw)).toBe("abcd-1234");
});
it("retourne null si absent", () => {
expect(parseBootIdBefore("rien")).toBeNull();
});
});
describe("classifyReboot", () => {
it("ok si la machine revient avec un boot_id différent", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: "B", wentDown: true, cameBack: true }).status).toBe("ok");
});
it("boot_id_unchanged si même boot_id", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: "A", wentDown: true, cameBack: true }).status).toBe("boot_id_unchanged");
});
it("ssh_never_went_down si la coupure n'a pas été observée", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: false, cameBack: false }).status).toBe("ssh_never_went_down");
});
it("machine_did_not_return si coupure mais pas de retour", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: true, cameBack: false }).status).toBe("machine_did_not_return");
});
});
```
- [ ] **Step 2 : Lancer (échec)**`rtk pnpm vitest run server/services/rebootVerify.test.ts` → FAIL.
- [ ] **Step 3 : Implémenter le socle pur dans `server/services/rebootVerify.ts`**
```ts
// server/services/rebootVerify.ts
import { runPlain, type SshCreds } from "../ssh/client.js";
import type { RebootResult } from "@shared/types.js";
export function parseBootIdBefore(raw: string): string | null {
const s = raw.indexOf("===SU:BOOT_ID_BEFORE===");
if (s === -1) return null;
const from = s + "===SU:BOOT_ID_BEFORE===".length;
const e = raw.indexOf("===SU:REBOOT_NOW===", from);
const id = raw.slice(from, e === -1 ? undefined : e).trim();
return id || null;
}
export interface RebootSignals {
beforeBootId: string | null;
afterBootId: string | null;
wentDown: boolean;
cameBack: boolean;
}
/** Détermine le statut d'un reboot vérifié (fonction pure). */
export function classifyReboot(s: RebootSignals): { status: RebootResult["status"] } {
if (!s.wentDown) return { status: "ssh_never_went_down" };
if (!s.cameBack || s.afterBootId === null) return { status: "machine_did_not_return" };
if (s.beforeBootId !== null && s.afterBootId === s.beforeBootId) return { status: "boot_id_unchanged" };
return { status: "ok" };
}
async function readBootId(creds: SshCreds): Promise<string | null> {
try {
const res = await runPlain(creds, "cat /proc/sys/kernel/random/boot_id");
const id = res.stdout.trim();
return id || null;
} catch {
return null; // connexion impossible (machine down)
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export interface VerifyOptions {
beforeBootId: string | null;
requestedAt: string;
downTimeoutMs?: number; // détection de la coupure
upTimeoutMs?: number; // attente du retour
pollMs?: number;
}
/**
* Orchestration : attend la coupure SSH (machine qui reboote) puis le retour,
* relit le boot_id, et classe le résultat. Réseau ; non testé unitairement.
*/
export async function verifyReboot(creds: SshCreds, opt: VerifyOptions): Promise<RebootResult> {
const downTimeoutMs = opt.downTimeoutMs ?? 60000;
const upTimeoutMs = opt.upTimeoutMs ?? 600000;
const pollMs = opt.pollMs ?? 5000;
const t0 = Date.now();
// Phase A : attendre que la machine devienne injoignable.
let wentDown = false;
let sshWentDownAt: string | null = null;
while (Date.now() - t0 < downTimeoutMs) {
const id = await readBootId(creds);
if (id === null) { wentDown = true; sshWentDownAt = new Date().toISOString(); break; }
await sleep(pollMs);
}
// Phase B : attendre le retour (seulement si on a vu la coupure).
let cameBack = false;
let sshCameBackAt: string | null = null;
let afterBootId: string | null = null;
if (wentDown) {
const tB = Date.now();
while (Date.now() - tB < upTimeoutMs) {
const id = await readBootId(creds);
if (id !== null) { cameBack = true; sshCameBackAt = new Date().toISOString(); afterBootId = id; break; }
await sleep(pollMs);
}
}
const { status } = classifyReboot({ beforeBootId: opt.beforeBootId, afterBootId, wentDown, cameBack });
const waitedSeconds = Math.round((Date.now() - t0) / 1000);
return {
beforeBootId: opt.beforeBootId,
afterBootId,
requestedAt: opt.requestedAt,
sshWentDownAt,
sshCameBackAt,
waitedSeconds,
status,
lastRebootDurationSeconds: status === "ok" ? waitedSeconds : undefined,
nextRecommendedWaitSeconds: status === "ok" ? Math.round(waitedSeconds * 1.5) + 30 : undefined,
};
}
```
- [ ] **Step 4 : Lancer (succès)**`rtk pnpm vitest run server/services/rebootVerify.test.ts` → PASS (6). `rtk pnpm check` → 0 erreur.
- [ ] **Step 5 : (pas de commit)**
---
## Task 3 : Router l'action `reboot_verified` dans `execute.ts`
**Files:** Modify `server/services/execute.ts`.
- [ ] **Step 1 : Relire `server/services/execute.ts`** (TEMPLATE_FOR, flux, blocs Phase 1).
- [ ] **Step 2 : Ajouter `reboot_verified` à `TEMPLATE_FOR`** (réutilise le même template que `reboot`)
```ts
reboot_verified: "apt/reboot.sh.tpl",
```
- [ ] **Step 3 : Après l'exécution du script (raw obtenu), lancer la vérification pour `reboot_verified`** et attacher `result.reboot`. Importer en tête :
```ts
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
import type { RebootResult } from "@shared/types.js";
```
Puis, après le bloc qui calcule `status`/`raw` et avant la construction de `result` (ou juste après, en enrichissant `result`), ajouter une branche :
```ts
let rebootResult: RebootResult | undefined;
if (action === "reboot_verified") {
const beforeBootId = parseBootIdBefore(raw);
rebootResult = await verifyReboot(getCreds(m), { beforeBootId, requestedAt: startedAt });
// Le statut de l'exécution suit la vérif : ok si reboot ok, sinon error.
if (rebootResult.status !== "ok") status = "error";
}
```
Puis dans la construction de `result`, ajouter `...(rebootResult ? { reboot: rebootResult } : {})` ; et conserver `rebootRequiredAfterRun` existant.
> ⚠️ `verifyReboot` est **long** (jusqu'à plusieurs minutes). C'est acceptable : `runAction` est déjà lancé en arrière-plan (la route POST renvoie 202). La sortie live reste streamée pendant l'attente n'est pas nécessaire ; on peut publier un message d'attente : `outputHub.publish(machineId, "\n[reboot] attente du redémarrage...\n")` avant `verifyReboot`.
- [ ] **Step 4 : Persister le délai adaptatif** (optionnel, simple) : après `verifyReboot`, si `rebootResult.lastRebootDurationSeconds`, l'écrire dans un event :
```ts
if (rebootResult.status === "ok") {
recordEvent({ machineId, eventType: "reboot_verified", severity: "info", executionId,
message: `Reboot vérifié en ${rebootResult.waitedSeconds}s (boot_id changé)` });
}
```
(Le stockage en colonne dédiée `machine_state` peut venir plus tard ; l'event suffit au MVP.)
- [ ] **Step 5 : Vérifier**`rtk pnpm check && rtk pnpm test` → 0 erreur, tests verts. Les blocs Phase 1 (executions/reports/rawArtifacts/state/event) restent intacts ; `reboot` (jalon 1) inchangé.
- [ ] **Step 6 : (pas de commit)**
---
## Task 4 : Vérification finale SJ-3
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert.
- [ ] **Step 2 : Boot smoke** (DB jetable) → `/health` OK. Nettoyer.
- [ ] **Step 3 :** Reporter. **Vérif live indispensable** : `reboot_verified` réel sur une machine de test (la boucle réseau attente-coupure/retour + comparaison `boot_id` ne peut être validée qu'en conditions réelles). **Ne pas committer.**
---
## Self-Review (couverture SJ-3)
- `apt/reboot.sh.tpl` capture `boot_id` → Task 1. ✓
- Orchestration backend (attente coupure → reconnexion délai adaptatif → relecture boot_id) → Task 2 (`verifyReboot`). ✓
- `RebootResult` + statuts (`ok`/`ssh_never_went_down`/`machine_did_not_return`/`boot_id_unchanged`/`timeout`) → `classifyReboot` (TDD) + `verifyReboot`. ✓
- Délai adaptatif `lastRebootDurationSeconds``nextRecommendedWaitSeconds``verifyReboot`. ✓
- Conserve l'action `reboot` jalon 1 → Task 3 (nouvelle action distincte). ✓
Décision : la boucle réseau utilise des timeouts bornés (down ≤ 60 s, up ≤ 600 s, poll 5 s) ; seule `classifyReboot` (+`parseBootIdBefore`) est testée unitairement, l'orchestration est validée en live. `timeout` (statut) est couvert par `machine_did_not_return` quand le retour n'arrive pas dans `upTimeoutMs` (mêmes conséquences ; un raffinement `timeout` explicite est notable mais non bloquant au MVP).