# 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 10–15 machines Proxmox, Debian et Ubuntu. Certaines machines sont des VMs avec disques en PCI passthrough (4–5 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