Files
mes_hdd/docs/superpowers/specs/2026-05-28-inventaire-hdd-design.md
T
Gilles SoulierandClaude Sonnet 4.6 ee0dc2461c docs: API complète + évolutivité schéma Alembic
- Routes read séparées dashboard vs agents IA
- Endpoints /api/ai/* (summary, at-risk, moved-disks, backup-needed)
- Stratégie migrations Alembic (alembic upgrade head au démarrage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 19:58:24 +02:00

406 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Inventaire HDD — Design Spec
**Date** : 2026-05-28
**Projet** : mes_hdd
**Statut** : approuvé
---
## Contexte
Outil de monitoring des disques physiques (HDD/SSD/NVMe) pour un parc de 1015 machines Proxmox, Debian et Ubuntu. Certaines machines sont des VMs avec disques en PCI passthrough (45 machines).
Le numéro de série est la **source de vérité unique** d'un disque : si un disque migre entre machines, si il tombe en panne ou est retiré, l'historique est conservé.
Le script est lancé **manuellement** par l'administrateur quand la configuration change (pas de cron, pas de daemon).
---
## Architecture globale
```
[Machine cliente — Proxmox / Debian / Ubuntu]
python3 inventaire.py (stdlib uniquement, pas de venv)
└── HTTP POST http://10.0.0.50:8088/api/ingest
[nginx:alpine — port 8088]
├── /api/* → proxy → [FastAPI :8000]
└── / → static (HTML + design system)
SQLite (volume Docker nommé)
```
**2 conteneurs Docker** via `docker-compose.yml` :
- `api` : FastAPI (Python), port interne 8000
- `web` : nginx:alpine, port exposé **8088**, sert les fichiers statiques et proxifie `/api/*`
SQLite dans un volume nommé (`mes_hdd_db`).
---
## Script client — `inventaire.py`
### Contraintes
- Python 3 uniquement, **stdlib exclusively** (`subprocess`, `json`, `urllib.request`)
- Aucun `pip install`, aucun venv, aucune dépendance externe
- Fonctionne sur Proxmox, Debian, Ubuntu
- **Doit être exécuté en root** (ou via `sudo`) : `smartctl` et `pvs`/`lvs` nécessitent des droits élevés
- Tolérant aux erreurs : un disque ou une commande qui échoue ne bloque pas l'inventaire des autres
- Si `smartctl` est absent → `smart.status = "unavailable"`
- Si `pvs`/`lvs` sont absents ou LVM non installé → partitions LVM ignorées, `lvm: null`
### Commandes utilisées
| Commande | Usage |
|---|---|
| `lsblk -J -o NAME,TYPE,SIZE,MODEL,SERIAL,FSTYPE,MOUNTPOINT,ROTA,UUID` | Structure des disques et partitions |
| `smartctl -H -A -i /dev/sdX` | Santé SMART + attributs |
| `df -B1` | Espace utilisé/libre par point de montage |
| `pvs --noheadings --reportformat json` | Volumes physiques LVM |
| `vgs --noheadings --reportformat json` | Groupes de volumes LVM |
| `lvs --noheadings --reportformat json -o lv_name,vg_name,lv_size,lv_path,lv_dm_path` | Volumes logiques LVM |
| `udevadm info --query=property --name=/dev/sdX` | Bus (sata/usb/nvme) |
| `find /dev/disk/by-id -type l` | Lien by-id (sans partitions) |
| `hostname`, `ip route get 1.1.1.1` | Identité de la machine |
| `du -sb /home/* 2>/dev/null` | Taille par utilisateur sous `/home` |
### Payload JSON envoyé
```json
{
"hostname": "pve1",
"ip": "10.0.0.10",
"collected_at": "2026-05-28T15:30:00+02:00",
"disks": [
{
"device": "sda",
"path": "/dev/sda",
"by_id": "ata-ST1000LM024_W1234567",
"model": "ST1000LM024 HN-M101MBB",
"serial": "W1234567",
"type": "HDD",
"capacity_bytes": 1000204886016,
"capacity_human": "1.0 To",
"bus": "sata",
"smart": {
"status": "ok",
"label": "Bon état",
"detail": "2 847h d'utilisation · 38°C · aucun secteur défectueux",
"temperature_c": 38,
"power_on_hours": 2847,
"reallocated_sectors": 0,
"pending_sectors": 0,
"uncorrectable_sectors": 0
},
"partitions": [
{
"name": "sda1",
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"fstype": "ext4",
"size_bytes": 536870912000,
"size_human": "500 Go",
"used_bytes": 128849018880,
"used_human": "120 Go",
"free_bytes": 408021991120,
"free_human": "380 Go",
"used_percent": 24,
"mountpoint": "/",
"home_users": null,
"lvm": null
},
{
"name": "sda2",
"uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"fstype": "LVM2_member",
"size_bytes": 536870912000,
"size_human": "500 Go",
"mountpoint": null,
"lvm": {
"vg_name": "vg_data",
"logical_volumes": [
{
"lv_name": "lv_home",
"size_human": "300 Go",
"used_human": "50 Go",
"free_human": "250 Go",
"used_percent": 17,
"fstype": "ext4",
"mountpoint": "/home"
},
{
"lv_name": "lv_swap",
"size_human": "8 Go",
"fstype": "swap",
"mountpoint": null
}
]
}
}
]
}
]
}
```
### Détection OS et adaptations
Le script lit `/etc/os-release` pour identifier l'environnement :
| OS détecté | Condition |
|---|---|
| `proxmox` | `/etc/pve` existe OU `ID=debian` + `VARIANT_ID=proxmox` |
| `ubuntu` | `ID=ubuntu` |
| `debian` | `ID=debian` (fallback) |
Le champ `os` est inclus dans le payload au niveau machine :
```json
{ "hostname": "pve1", "os": "proxmox", "os_version": "8.2", ... }
```
**Adaptations par OS** :
- **Proxmox** : enrichissement additionnel si disponible
- Détection ZFS : `zpool list -H -o name,size,alloc,free` → ajoute `zfs_pool` sur le disque concerné
- Détection Ceph OSD : si `/var/lib/ceph/osd` contient des liens vers le disque → champ `proxmox_role: "ceph_osd"`
- Détection stockage VM : si disque dans `/etc/pve/storage.cfg``proxmox_role: "vm_storage"`
- Les boucles (`loop*`) et devices virtuels (`dm-*`) sont ignorés comme partout
- **Ubuntu** : filtre des montages `squashfs` (snap) dans la liste des partitions — ils polluent `df` et `lsblk`
- **Debian** : comportement par défaut, aucune adaptation spécifique
---
### Taille du dossier `/home`
Si une partition est montée sur `/home` (ou si `/home` est dans la racine), le script calcule la taille occupée par chaque sous-dossier utilisateur via `du -sb /home/* 2>/dev/null`.
Le champ `home_users` est ajouté à la partition dont le point de montage est `/home` (ou `/` si `/home` n'est pas une partition dédiée) :
```json
"home_users": [
{ "user": "gilles", "size_bytes": 91268055040, "size_human": "85 Go" },
{ "user": "alice", "size_bytes": 32212254720, "size_human": "30 Go" }
]
```
Si `/home` n'existe pas ou est vide → `home_users: []`.
Si `du` échoue → `home_users: null`.
Cela permet d'identifier côté frontend les disques avec des données significatives à sauvegarder.
---
### SMART — labels en français
| Cas | status | label | Exemple de detail |
|---|---|---|---|
| Test global PASSED, pas d'attribut dégradé | `ok` | Bon état | "2 847h · 38°C · aucun secteur défectueux" |
| PASSED mais attributs FAILING_NOW / In_the_past | `warn` | Attention | "3 secteurs réalloués · disque à surveiller" |
| Test global FAILED | `fail` | Défaillance probable | "Prévoir le remplacement du disque" |
| smartctl absent ou inaccessible | `unavailable` | SMART indisponible | "smartctl non disponible ou accès refusé" |
Les champs numériques (`temperature_c`, `power_on_hours`, `reallocated_sectors`, `pending_sectors`, `uncorrectable_sectors`) sont inclus quand disponibles, `null` sinon.
---
## Backend — FastAPI
### Routes complètes
**Écriture**
| Méthode | Route | Description |
|---|---|---|
| `POST` | `/api/ingest` | Reçoit le payload complet du script client |
**Lecture — Dashboard / frontend**
| Méthode | Route | Description |
|---|---|---|
| `GET` | `/api/disks` | Tous les disques — dernière observation par serial |
| `GET` | `/api/disks/{serial}` | Détail + historique complet d'un disque |
| `GET` | `/api/machines` | Toutes les machines avec last_seen et nombre de disques |
| `GET` | `/api/machines/{hostname}` | Détail d'une machine + ses disques courants |
| `GET` | `/api/machines/{hostname}/disks` | Disques d'une machine (dernière observation) |
**Lecture — Agents IA et apps externes**
| Méthode | Route | Description |
|---|---|---|
| `GET` | `/api/ai/summary` | Synthèse globale : machines, disques, statuts SMART, espaces |
| `GET` | `/api/ai/at-risk` | Disques en `warn` ou `fail` avec détail SMART |
| `GET` | `/api/ai/moved-disks` | Disques dont `last_seen_host ≠ first_seen_host` |
| `GET` | `/api/ai/backup-needed` | Disques avec `home_users` non vide, triés par taille décroissante |
| `GET` | `/api/ai/machines/{hostname}` | Vue complète d'une machine pour agent IA |
Les routes `/api/ai/*` retournent du JSON compact et plat, sans pagination ni enveloppe, optimisé pour consommation LLM.
### Évolutivité du schéma SQLite — Alembic
Le schéma évolue via **Alembic** (migrations versionnées) :
```
api/
├── alembic.ini
├── migrations/
│ ├── env.py
│ └── versions/
│ └── 0001_initial_schema.py
```
- Chaque ajout de colonne ou de table = une nouvelle migration numérotée
- Appliquées automatiquement au démarrage du conteneur (`alembic upgrade head`)
- Permet d'ajouter des champs sans recréer la base ni perdre l'historique
### Logique d'ingest
À chaque POST `/api/ingest` :
1. Upsert dans `machines` (hostname, ip, last_seen)
2. Pour chaque disque du payload :
- Upsert dans `disks` (serial comme PK) — met à jour `last_host`, `last_seen`, `status`
- Si `last_host``first_host` → le mouvement est détecté (visible via l'API)
- Insert dans `snapshots` (historique immuable)
---
## Base de données — SQLite
### Table `disks`
| Colonne | Type | Description |
|---|---|---|
| `serial` | TEXT PK | Numéro de série (source de vérité) |
| `model` | TEXT | Modèle du disque |
| `type` | TEXT | HDD / SSD / NVMe / inconnu |
| `capacity_bytes` | INTEGER | Capacité en octets |
| `capacity_human` | TEXT | Capacité lisible |
| `first_seen_host` | TEXT | Première machine observée |
| `first_seen_at` | TEXT | Date première observation |
| `last_seen_host` | TEXT | Dernière machine observée |
| `last_seen_at` | TEXT | Date dernière observation |
| `smart_status` | TEXT | Dernier status SMART |
### Table `snapshots`
| Colonne | Type | Description |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `serial` | TEXT FK → disks | |
| `hostname` | TEXT | Machine au moment de la collecte |
| `device` | TEXT | ex: sda, nvme0n1 |
| `smart_status` | TEXT | ok / warn / fail / unavailable |
| `smart_label` | TEXT | Label français |
| `smart_detail` | TEXT | Détail lisible |
| `smart_raw_json` | TEXT | Attributs bruts (JSON) |
| `partitions_json` | TEXT | Partitions + LVM + UUID (JSON) |
| `collected_at` | TEXT | ISO 8601 |
### Table `machines`
| Colonne | Type | Description |
|---|---|---|
| `hostname` | TEXT PK | |
| `ip` | TEXT | |
| `last_seen` | TEXT | ISO 8601 |
---
## Frontend (étape 2)
Design system **Gruvbox Seventies** — React 18 via CDN + Babel standalone (pas de build step).
Fichiers statiques servis par nginx, localisés dans `frontend/` :
- `index.html` — point d'entrée, charge tokens.css + ui-kit.jsx via CDN/local
- `app.jsx` — composant principal React (chargé Babel)
Structure de page prévue :
- Colonne gauche : `TreeNav` des machines, avec nombre de disques et statut global
- Zone principale : cartes disques par machine
- `StatusLed` (ok/warn/err) + modèle + serial
- `BatteryGauge` espace par partition/LV
- Détail partitions + LVM dans un `Popup`
- Indicateur visuel de disque déplacé (serial apparu sur nouvelle machine)
---
## Docker Compose
```yaml
services:
api:
build: ./api
restart: unless-stopped
volumes:
- mes_hdd_db:/data
environment:
DB_PATH: /data/mes_hdd.db
expose:
- "8000"
web:
image: nginx:alpine
restart: unless-stopped
ports:
- "8088:80"
volumes:
- ./frontend:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
volumes:
mes_hdd_db:
```
nginx proxifie `/api/*` vers `api:8000`, sert `/` depuis les fichiers statiques.
---
## Lancement du script client
### Dépôt source
Le script est versionné dans le dépôt Gitea :
`https://git.maison43gil.com/gilles/mes_hdd`
URL raw du script :
`https://git.maison43gil.com/gilles/mes_hdd/raw/branch/main/inventaire.py`
### Commande one-liner (affichée par le frontend)
```bash
curl -fsSL https://git.maison43gil.com/gilles/mes_hdd/raw/branch/main/inventaire.py | sudo python3 -
```
Le frontend propose cette commande en copier-coller sur la page d'accueil, avec l'URL de l'API pré-remplie via variable d'environnement :
```bash
curl -fsSL https://git.maison43gil.com/gilles/mes_hdd/raw/branch/main/inventaire.py \
| sudo MES_HDD_API=http://10.0.0.50:8088 python3 -
```
### Variables d'environnement du script
| Variable | Défaut | Description |
|---|---|---|
| `MES_HDD_API` | `http://10.0.0.50:8088` | URL de base de l'API |
### Exécution locale
```bash
sudo python3 inventaire.py
# ou
sudo MES_HDD_API=http://192.168.1.x:8088 python3 inventaire.py
```
---
## Ce qui est hors scope (étape 2 ou plus tard)
- Authentification sur l'API
- Alertes automatiques (email, Telegram) en cas de SMART dégradé
- Scraping périodique automatique (cron distant)
- Interface mobile