a0f47bf966
Ajoute les trois plans d'implémentation (agent Rust, serveur Go, dashboard), les consignes de design, les fichiers de brainstorming et le .gitignore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1352 lines
33 KiB
Markdown
1352 lines
33 KiB
Markdown
# Nanometrics Agent Rust — Plan d'implémentation
|
||
|
||
> **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:** Créer un agent de collecte de métriques système (CPU, RAM, disque, réseau, uptime, température, SMART) en Rust, sans runtime async, transmettant via UDP JSON et/ou MQTT avec config bidirectionnelle.
|
||
|
||
**Architecture:** Boucle mono-thread avec `std::thread::sleep` pour les fréquences différenciées (CPU/RAM 2s, réseau/uptime 10s, disque/SMART 60s). Le transport MQTT tourne dans un thread dédié communiquant via `mpsc::channel`. La config est lue depuis `config.toml` et peut être mise à jour depuis le serveur via MQTT.
|
||
|
||
**Tech Stack:** Rust stable, sysinfo 0.30 (`default-features = false`), serde + serde_json 1, rumqttc 0.24, toml 0.8
|
||
|
||
---
|
||
|
||
## Structure des fichiers
|
||
|
||
```
|
||
agent/
|
||
├── Cargo.toml
|
||
├── config.toml.example
|
||
├── src/
|
||
│ ├── main.rs — boucle principale, orchestration
|
||
│ ├── config.rs — chargement + structs Config
|
||
│ ├── payload.rs — struct AgentMetrics (serde Serialize)
|
||
│ ├── metrics/
|
||
│ │ ├── mod.rs
|
||
│ │ ├── cpu.rs — cpu_percent via sysinfo
|
||
│ │ ├── memory.rs — used/free/total RAM
|
||
│ │ ├── disk.rs — used/free/total disque
|
||
│ │ ├── network.rs — rx/tx cumulatifs
|
||
│ │ ├── uptime.rs — System::uptime()
|
||
│ │ ├── temperature.rs — composants CPU (optionnel)
|
||
│ │ └── smart.rs — smartctl -j, parsing JSON
|
||
│ └── transport/
|
||
│ ├── mod.rs
|
||
│ ├── udp.rs — UdpSocket fire-and-forget
|
||
│ └── mqtt.rs — thread MQTT + channels
|
||
├── tests/
|
||
│ ├── config_test.rs
|
||
│ ├── payload_test.rs
|
||
│ └── smart_test.rs
|
||
└── deploy/
|
||
└── nanometrics-agent.service
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1 : Setup Cargo.toml et structure
|
||
|
||
**Files:**
|
||
- Create: `agent/Cargo.toml`
|
||
- Create: `agent/config.toml.example`
|
||
- Create: `agent/src/main.rs` (squelette)
|
||
|
||
- [ ] **Créer `agent/Cargo.toml`**
|
||
|
||
```toml
|
||
[package]
|
||
name = "nanometrics-agent"
|
||
version = "0.1.0"
|
||
edition = "2021"
|
||
|
||
[profile.release]
|
||
strip = true
|
||
opt-level = "z"
|
||
lto = true
|
||
codegen-units = 1
|
||
|
||
[dependencies]
|
||
sysinfo = { version = "0.30", default-features = false, features = ["system", "networks", "disk"] }
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
toml = "0.8"
|
||
rumqttc = { version = "0.24", default-features = false, features = ["use-native-tls"] }
|
||
|
||
[dev-dependencies]
|
||
tempfile = "3"
|
||
```
|
||
|
||
- [ ] **Créer `agent/config.toml.example`**
|
||
|
||
```toml
|
||
[server]
|
||
ip = "10.0.0.50"
|
||
port = 9999
|
||
|
||
[protocols.udp]
|
||
enabled = true
|
||
|
||
[protocols.mqtt]
|
||
enabled = false
|
||
host = "10.0.0.3"
|
||
port = 1883
|
||
topic_base = "nanometrics/agents"
|
||
auto_discovery = true
|
||
birth_message = true
|
||
last_will = true
|
||
|
||
[metrics.cpu]
|
||
udp = true
|
||
mqtt = true
|
||
|
||
[metrics.memory]
|
||
udp = true
|
||
mqtt = true
|
||
|
||
[metrics.disk]
|
||
udp = true
|
||
mqtt = false
|
||
|
||
[metrics.network]
|
||
udp = false
|
||
mqtt = false
|
||
|
||
[metrics.uptime]
|
||
udp = true
|
||
mqtt = false
|
||
|
||
[metrics.temperature]
|
||
udp = false
|
||
mqtt = false
|
||
|
||
[metrics.smart]
|
||
udp = true
|
||
mqtt = false
|
||
```
|
||
|
||
- [ ] **Créer `agent/src/main.rs` (squelette compilable)**
|
||
|
||
```rust
|
||
mod config;
|
||
mod metrics;
|
||
mod payload;
|
||
mod transport;
|
||
|
||
fn main() {
|
||
println!("nanometrics-agent starting");
|
||
}
|
||
```
|
||
|
||
- [ ] **Vérifier que ça compile**
|
||
|
||
```bash
|
||
cd agent && rtk cargo check
|
||
```
|
||
|
||
Résultat attendu : `Checking nanometrics-agent v0.1.0` sans erreurs.
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
rtk git add agent/Cargo.toml agent/config.toml.example agent/src/main.rs
|
||
rtk git commit -m "feat(agent): setup Cargo.toml et squelette"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2 : Config struct
|
||
|
||
**Files:**
|
||
- Create: `agent/src/config.rs`
|
||
- Create: `agent/tests/config_test.rs`
|
||
|
||
- [ ] **Écrire le test en premier**
|
||
|
||
`agent/tests/config_test.rs` :
|
||
|
||
```rust
|
||
use std::io::Write;
|
||
use tempfile::NamedTempFile;
|
||
|
||
#[test]
|
||
fn test_config_parse_complet() {
|
||
let mut f = NamedTempFile::new().unwrap();
|
||
write!(f, r#"
|
||
[server]
|
||
ip = "10.0.0.50"
|
||
port = 9999
|
||
|
||
[protocols.udp]
|
||
enabled = true
|
||
|
||
[protocols.mqtt]
|
||
enabled = true
|
||
host = "10.0.0.3"
|
||
port = 1883
|
||
topic_base = "nanometrics/agents"
|
||
auto_discovery = true
|
||
birth_message = true
|
||
last_will = true
|
||
|
||
[metrics.cpu]
|
||
udp = true
|
||
mqtt = false
|
||
"#).unwrap();
|
||
let cfg = nanometrics_agent::config::load(f.path()).unwrap();
|
||
assert_eq!(cfg.server.ip, "10.0.0.50");
|
||
assert_eq!(cfg.server.port, 9999);
|
||
assert!(cfg.protocols.udp.enabled);
|
||
assert!(cfg.protocols.mqtt.enabled);
|
||
assert_eq!(cfg.protocols.mqtt.host, "10.0.0.3");
|
||
assert!(cfg.metrics.cpu.udp);
|
||
assert!(!cfg.metrics.cpu.mqtt);
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_mqtt_absent() {
|
||
let mut f = NamedTempFile::new().unwrap();
|
||
write!(f, r#"
|
||
[server]
|
||
ip = "10.0.0.50"
|
||
port = 9999
|
||
|
||
[protocols.udp]
|
||
enabled = true
|
||
|
||
[protocols.mqtt]
|
||
enabled = false
|
||
"#).unwrap();
|
||
let cfg = nanometrics_agent::config::load(f.path()).unwrap();
|
||
assert!(!cfg.protocols.mqtt.enabled);
|
||
}
|
||
```
|
||
|
||
- [ ] **Lancer le test — doit échouer (module absent)**
|
||
|
||
```bash
|
||
cd agent && rtk cargo test
|
||
```
|
||
|
||
Résultat attendu : `error[E0432]: unresolved import`
|
||
|
||
- [ ] **Créer `agent/src/config.rs`**
|
||
|
||
```rust
|
||
use serde::Deserialize;
|
||
use std::path::Path;
|
||
|
||
#[derive(Deserialize, Debug, Clone)]
|
||
pub struct Config {
|
||
pub server: ServerConfig,
|
||
pub protocols: ProtocolsConfig,
|
||
#[serde(default)]
|
||
pub metrics: MetricsConfig,
|
||
}
|
||
|
||
#[derive(Deserialize, Debug, Clone)]
|
||
pub struct ServerConfig {
|
||
pub ip: String,
|
||
pub port: u16,
|
||
}
|
||
|
||
#[derive(Deserialize, Debug, Clone, Default)]
|
||
pub struct ProtocolsConfig {
|
||
#[serde(default)]
|
||
pub udp: UdpConfig,
|
||
#[serde(default)]
|
||
pub mqtt: MqttConfig,
|
||
}
|
||
|
||
#[derive(Deserialize, Debug, Clone, Default)]
|
||
pub struct UdpConfig {
|
||
#[serde(default)]
|
||
pub enabled: bool,
|
||
}
|
||
|
||
#[derive(Deserialize, Debug, Clone, Default)]
|
||
pub struct MqttConfig {
|
||
#[serde(default)]
|
||
pub enabled: bool,
|
||
#[serde(default = "default_mqtt_host")]
|
||
pub host: String,
|
||
#[serde(default = "default_mqtt_port")]
|
||
pub port: u16,
|
||
#[serde(default = "default_topic_base")]
|
||
pub topic_base: String,
|
||
#[serde(default = "default_true")]
|
||
pub auto_discovery: bool,
|
||
#[serde(default = "default_true")]
|
||
pub birth_message: bool,
|
||
#[serde(default = "default_true")]
|
||
pub last_will: bool,
|
||
}
|
||
|
||
fn default_mqtt_host() -> String { "10.0.0.3".to_string() }
|
||
fn default_mqtt_port() -> u16 { 1883 }
|
||
fn default_topic_base() -> String { "nanometrics/agents".to_string() }
|
||
fn default_true() -> bool { true }
|
||
|
||
#[derive(Deserialize, Debug, Clone, Default)]
|
||
pub struct MetricsConfig {
|
||
#[serde(default)]
|
||
pub cpu: MetricProto,
|
||
#[serde(default)]
|
||
pub memory: MetricProto,
|
||
#[serde(default)]
|
||
pub disk: MetricProto,
|
||
#[serde(default)]
|
||
pub network: MetricProto,
|
||
#[serde(default)]
|
||
pub uptime: MetricProto,
|
||
#[serde(default)]
|
||
pub temperature: MetricProto,
|
||
#[serde(default)]
|
||
pub smart: MetricProto,
|
||
}
|
||
|
||
#[derive(Deserialize, Debug, Clone, Default)]
|
||
pub struct MetricProto {
|
||
#[serde(default)]
|
||
pub udp: bool,
|
||
#[serde(default)]
|
||
pub mqtt: bool,
|
||
}
|
||
|
||
pub fn load(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
|
||
let content = std::fs::read_to_string(path)?;
|
||
let config: Config = toml::from_str(&content)?;
|
||
Ok(config)
|
||
}
|
||
```
|
||
|
||
- [ ] **Exposer `config` comme module public dans `lib.rs`** (pour les tests d'intégration)
|
||
|
||
Créer `agent/src/lib.rs` :
|
||
|
||
```rust
|
||
pub mod config;
|
||
pub mod metrics;
|
||
pub mod payload;
|
||
pub mod transport;
|
||
```
|
||
|
||
Modifier `agent/src/main.rs` :
|
||
|
||
```rust
|
||
use nanometrics_agent::config;
|
||
|
||
mod metrics;
|
||
mod payload;
|
||
mod transport;
|
||
|
||
fn main() {
|
||
let path = std::path::Path::new("config.toml");
|
||
let _cfg = config::load(path).expect("Impossible de charger config.toml");
|
||
println!("nanometrics-agent démarré");
|
||
}
|
||
```
|
||
|
||
- [ ] **Lancer les tests — doivent passer**
|
||
|
||
```bash
|
||
cd agent && rtk cargo test config
|
||
```
|
||
|
||
Résultat attendu : `test test_config_parse_complet ... ok` et `test test_config_mqtt_absent ... ok`
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
rtk git add agent/src/config.rs agent/src/lib.rs agent/src/main.rs agent/tests/config_test.rs
|
||
rtk git commit -m "feat(agent): config struct + tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3 : Payload AgentMetrics
|
||
|
||
**Files:**
|
||
- Create: `agent/src/payload.rs`
|
||
- Create: `agent/tests/payload_test.rs`
|
||
|
||
- [ ] **Écrire le test**
|
||
|
||
`agent/tests/payload_test.rs` :
|
||
|
||
```rust
|
||
use nanometrics_agent::payload::AgentMetrics;
|
||
|
||
#[test]
|
||
fn test_serialize_json_complet() {
|
||
let m = AgentMetrics {
|
||
hostname: "srv-01".to_string(),
|
||
ip: "10.0.0.11".to_string(),
|
||
cpu_percent: Some(42.5),
|
||
memory_used: Some(3_000_000_000),
|
||
memory_free: Some(5_000_000_000),
|
||
memory_total: Some(8_000_000_000),
|
||
hdd_used: Some(60_000_000_000),
|
||
hdd_free: Some(140_000_000_000),
|
||
hdd_total: Some(200_000_000_000),
|
||
uptime: Some(1234567),
|
||
network_rx: Some(1024),
|
||
network_tx: Some(512),
|
||
temperature: None,
|
||
smart: None,
|
||
status: "online".to_string(),
|
||
};
|
||
let json = serde_json::to_string(&m).unwrap();
|
||
assert!(json.contains("\"hostname\":\"srv-01\""));
|
||
assert!(json.contains("\"cpu_percent\":42.5"));
|
||
assert!(json.contains("\"status\":\"online\""));
|
||
assert!(json.contains("\"temperature\":null"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_serialize_avec_smart() {
|
||
use nanometrics_agent::payload::SmartMetrics;
|
||
let m = AgentMetrics {
|
||
hostname: "srv-01".to_string(),
|
||
ip: "10.0.0.11".to_string(),
|
||
smart: Some(SmartMetrics {
|
||
passed: true,
|
||
temperature: Some(34),
|
||
reallocated_sectors: Some(0),
|
||
power_on_hours: Some(4213),
|
||
wear_level: Some(98),
|
||
}),
|
||
status: "online".to_string(),
|
||
..Default::default()
|
||
};
|
||
let json = serde_json::to_string(&m).unwrap();
|
||
assert!(json.contains("\"passed\":true"));
|
||
assert!(json.contains("\"temperature\":34"));
|
||
}
|
||
```
|
||
|
||
- [ ] **Lancer le test — doit échouer**
|
||
|
||
```bash
|
||
cd agent && rtk cargo test payload
|
||
```
|
||
|
||
- [ ] **Créer `agent/src/payload.rs`**
|
||
|
||
```rust
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||
pub struct AgentMetrics {
|
||
pub hostname: String,
|
||
pub ip: String,
|
||
pub status: String,
|
||
pub cpu_percent: Option<f32>,
|
||
pub memory_used: Option<u64>,
|
||
pub memory_free: Option<u64>,
|
||
pub memory_total: Option<u64>,
|
||
pub hdd_used: Option<u64>,
|
||
pub hdd_free: Option<u64>,
|
||
pub hdd_total: Option<u64>,
|
||
pub uptime: Option<u64>,
|
||
pub network_rx: Option<u64>,
|
||
pub network_tx: Option<u64>,
|
||
pub temperature: Option<f32>,
|
||
pub smart: Option<SmartMetrics>,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||
pub struct SmartMetrics {
|
||
pub passed: bool,
|
||
pub temperature: Option<i64>,
|
||
pub reallocated_sectors: Option<i64>,
|
||
pub power_on_hours: Option<i64>,
|
||
pub wear_level: Option<i64>,
|
||
}
|
||
```
|
||
|
||
- [ ] **Lancer les tests — doivent passer**
|
||
|
||
```bash
|
||
cd agent && rtk cargo test payload
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
rtk git add agent/src/payload.rs agent/tests/payload_test.rs
|
||
rtk git commit -m "feat(agent): payload AgentMetrics + tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4 : Métriques CPU, RAM, réseau, uptime
|
||
|
||
**Files:**
|
||
- Create: `agent/src/metrics/mod.rs`
|
||
- Create: `agent/src/metrics/cpu.rs`
|
||
- Create: `agent/src/metrics/memory.rs`
|
||
- Create: `agent/src/metrics/network.rs`
|
||
- Create: `agent/src/metrics/uptime.rs`
|
||
|
||
- [ ] **Créer `agent/src/metrics/mod.rs`**
|
||
|
||
```rust
|
||
pub mod cpu;
|
||
pub mod disk;
|
||
pub mod memory;
|
||
pub mod network;
|
||
pub mod smart;
|
||
pub mod temperature;
|
||
pub mod uptime;
|
||
```
|
||
|
||
- [ ] **Créer `agent/src/metrics/cpu.rs`**
|
||
|
||
```rust
|
||
use sysinfo::System;
|
||
|
||
/// Retourne le pourcentage CPU moyen global (0.0–100.0).
|
||
/// Appeler sys.refresh_cpu_all() avant.
|
||
pub fn get(sys: &System) -> f32 {
|
||
let cpus = sys.cpus();
|
||
if cpus.is_empty() {
|
||
return 0.0;
|
||
}
|
||
let total: f32 = cpus.iter().map(|c| c.cpu_usage()).sum();
|
||
total / cpus.len() as f32
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use sysinfo::System;
|
||
|
||
#[test]
|
||
fn test_cpu_in_range() {
|
||
let mut sys = System::new();
|
||
sys.refresh_cpu_all();
|
||
// Premier appel souvent 0, second plus représentatif
|
||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||
sys.refresh_cpu_all();
|
||
let val = get(&sys);
|
||
assert!(val >= 0.0 && val <= 100.0, "CPU hors plage : {}", val);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Créer `agent/src/metrics/memory.rs`**
|
||
|
||
```rust
|
||
use sysinfo::System;
|
||
|
||
/// (used_bytes, free_bytes, total_bytes)
|
||
pub fn get(sys: &System) -> (u64, u64, u64) {
|
||
(sys.used_memory(), sys.free_memory(), sys.total_memory())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use sysinfo::System;
|
||
|
||
#[test]
|
||
fn test_memory_coherent() {
|
||
let mut sys = System::new();
|
||
sys.refresh_memory();
|
||
let (used, free, total) = get(&sys);
|
||
assert!(total > 0, "Total mémoire nul");
|
||
assert!(used + free <= total + 1024 * 1024, "used + free > total");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Créer `agent/src/metrics/network.rs`**
|
||
|
||
```rust
|
||
use sysinfo::{Networks, NetworkExt};
|
||
|
||
/// (rx_bytes_total, tx_bytes_total) — cumulatif depuis le boot.
|
||
pub fn get(networks: &Networks) -> (u64, u64) {
|
||
let rx: u64 = networks.iter().map(|(_, n)| n.total_received()).sum();
|
||
let tx: u64 = networks.iter().map(|(_, n)| n.total_transmitted()).sum();
|
||
(rx, tx)
|
||
}
|
||
```
|
||
|
||
- [ ] **Créer `agent/src/metrics/uptime.rs`**
|
||
|
||
```rust
|
||
/// Retourne l'uptime en secondes depuis le boot.
|
||
pub fn get() -> u64 {
|
||
sysinfo::System::uptime()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
#[test]
|
||
fn test_uptime_positif() {
|
||
let up = super::get();
|
||
assert!(up > 0, "uptime nul ou négatif");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Lancer les tests unitaires**
|
||
|
||
```bash
|
||
cd agent && rtk cargo test metrics
|
||
```
|
||
|
||
Résultat attendu : tous les tests `ok`.
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
rtk git add agent/src/metrics/
|
||
rtk git commit -m "feat(agent): métriques CPU, RAM, réseau, uptime"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5 : Métriques disque et température
|
||
|
||
**Files:**
|
||
- Create: `agent/src/metrics/disk.rs`
|
||
- Create: `agent/src/metrics/temperature.rs`
|
||
|
||
- [ ] **Créer `agent/src/metrics/disk.rs`**
|
||
|
||
```rust
|
||
use sysinfo::{Disks, DiskExt};
|
||
|
||
/// (used_bytes, free_bytes, total_bytes) pour le premier disque monté sur /
|
||
/// Si aucun disque trouvé, retourne (0, 0, 0).
|
||
pub fn get(disks: &Disks) -> (u64, u64, u64) {
|
||
for disk in disks.iter() {
|
||
let mount = disk.mount_point().to_string_lossy();
|
||
if mount == "/" {
|
||
let total = disk.total_space();
|
||
let free = disk.available_space();
|
||
let used = total.saturating_sub(free);
|
||
return (used, free, total);
|
||
}
|
||
}
|
||
// Fallback : premier disque disponible
|
||
if let Some(disk) = disks.iter().next() {
|
||
let total = disk.total_space();
|
||
let free = disk.available_space();
|
||
return (total.saturating_sub(free), free, total);
|
||
}
|
||
(0, 0, 0)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use sysinfo::Disks;
|
||
|
||
#[test]
|
||
fn test_disk_coherent() {
|
||
let mut disks = Disks::new_with_refreshed_list();
|
||
let (used, free, total) = get(&disks);
|
||
if total > 0 {
|
||
assert!(used + free <= total + 1024, "used + free > total");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Créer `agent/src/metrics/temperature.rs`**
|
||
|
||
```rust
|
||
use sysinfo::{Components, ComponentExt};
|
||
|
||
/// Température CPU en °C (première composante "Core 0" ou similaire).
|
||
/// Retourne None si aucune composante disponible.
|
||
pub fn get(components: &Components) -> Option<f32> {
|
||
for comp in components.iter() {
|
||
let label = comp.label().to_lowercase();
|
||
if label.contains("core 0") || label.contains("cpu") || label.contains("tdie") {
|
||
return Some(comp.temperature());
|
||
}
|
||
}
|
||
None
|
||
}
|
||
```
|
||
|
||
- [ ] **Lancer les tests**
|
||
|
||
```bash
|
||
cd agent && rtk cargo test disk
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
rtk git add agent/src/metrics/disk.rs agent/src/metrics/temperature.rs
|
||
rtk git commit -m "feat(agent): métriques disque et température"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6 : SMART (smartctl)
|
||
|
||
**Files:**
|
||
- Create: `agent/src/metrics/smart.rs`
|
||
- Create: `agent/tests/smart_test.rs`
|
||
|
||
- [ ] **Écrire le test avec JSON simulé**
|
||
|
||
`agent/tests/smart_test.rs` :
|
||
|
||
```rust
|
||
use nanometrics_agent::metrics::smart;
|
||
|
||
const SMART_JSON_OK: &str = r#"{
|
||
"smart_status": {"passed": true},
|
||
"temperature": {"current": 34},
|
||
"ata_smart_attributes": {
|
||
"table": [
|
||
{"id": 5, "name": "Reallocated_Sector_Ct", "raw": {"value": 0}, "value": 200},
|
||
{"id": 9, "name": "Power_On_Hours", "raw": {"value": 4213}, "value": 77},
|
||
{"id": 177, "name": "Wear_Leveling_Count", "raw": {"value": 0}, "value": 98}
|
||
]
|
||
}
|
||
}"#;
|
||
|
||
#[test]
|
||
fn test_parse_smart_json_ok() {
|
||
let result = smart::parse_json(SMART_JSON_OK).unwrap();
|
||
assert!(result.passed);
|
||
assert_eq!(result.temperature, Some(34));
|
||
assert_eq!(result.reallocated_sectors, Some(0));
|
||
assert_eq!(result.power_on_hours, Some(4213));
|
||
assert_eq!(result.wear_level, Some(98));
|
||
}
|
||
|
||
const SMART_JSON_FAIL: &str = r#"{
|
||
"smart_status": {"passed": false},
|
||
"temperature": {"current": 52},
|
||
"ata_smart_attributes": {
|
||
"table": [
|
||
{"id": 5, "name": "Reallocated_Sector_Ct", "raw": {"value": 47}, "value": 150}
|
||
]
|
||
}
|
||
}"#;
|
||
|
||
#[test]
|
||
fn test_parse_smart_json_fail() {
|
||
let result = smart::parse_json(SMART_JSON_FAIL).unwrap();
|
||
assert!(!result.passed);
|
||
assert_eq!(result.reallocated_sectors, Some(47));
|
||
}
|
||
|
||
#[test]
|
||
fn test_smart_disponible() {
|
||
// Vérifie juste que la fonction ne panique pas
|
||
let _ = smart::is_available();
|
||
}
|
||
```
|
||
|
||
- [ ] **Lancer le test — doit échouer**
|
||
|
||
```bash
|
||
cd agent && rtk cargo test smart
|
||
```
|
||
|
||
- [ ] **Créer `agent/src/metrics/smart.rs`**
|
||
|
||
```rust
|
||
use serde::Deserialize;
|
||
use crate::payload::SmartMetrics;
|
||
|
||
#[derive(Deserialize)]
|
||
struct SmartJson {
|
||
smart_status: SmartStatus,
|
||
temperature: Option<SmartTemp>,
|
||
ata_smart_attributes: Option<SmartAttrs>,
|
||
nvme_smart_health_information_log: Option<NvmeHealth>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct SmartStatus { passed: bool }
|
||
|
||
#[derive(Deserialize)]
|
||
struct SmartTemp { current: i64 }
|
||
|
||
#[derive(Deserialize)]
|
||
struct SmartAttrs { table: Vec<SmartAttr> }
|
||
|
||
#[derive(Deserialize)]
|
||
struct SmartAttr {
|
||
id: u8,
|
||
raw: SmartAttrRaw,
|
||
value: Option<i64>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct SmartAttrRaw { value: i64 }
|
||
|
||
#[derive(Deserialize)]
|
||
struct NvmeHealth {
|
||
percentage_used: Option<i64>,
|
||
temperature: Option<i64>,
|
||
}
|
||
|
||
/// Retourne true si smartctl est disponible dans PATH.
|
||
pub fn is_available() -> bool {
|
||
std::process::Command::new("which")
|
||
.arg("smartctl")
|
||
.output()
|
||
.map(|o| o.status.success())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Parse un JSON produit par `smartctl -j /dev/xxx`.
|
||
pub fn parse_json(json: &str) -> Result<SmartMetrics, serde_json::Error> {
|
||
let s: SmartJson = serde_json::from_str(json)?;
|
||
|
||
let temperature = s.temperature.as_ref().map(|t| t.current)
|
||
.or_else(|| s.nvme_smart_health_information_log.as_ref()?.temperature);
|
||
|
||
let mut reallocated = None;
|
||
let mut power_hours = None;
|
||
let mut wear = None;
|
||
|
||
if let Some(attrs) = &s.ata_smart_attributes {
|
||
for attr in &attrs.table {
|
||
match attr.id {
|
||
5 => reallocated = Some(attr.raw.value),
|
||
9 => power_hours = Some(attr.raw.value),
|
||
177 => wear = attr.value,
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// NVMe : wear depuis percentage_used
|
||
if wear.is_none() {
|
||
if let Some(nvme) = &s.nvme_smart_health_information_log {
|
||
if let Some(pct) = nvme.percentage_used {
|
||
wear = Some(100 - pct);
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(SmartMetrics {
|
||
passed: s.smart_status.passed,
|
||
temperature,
|
||
reallocated_sectors: reallocated,
|
||
power_on_hours: power_hours,
|
||
wear_level: wear,
|
||
})
|
||
}
|
||
|
||
/// Collecte SMART sur le premier disque trouvé (/dev/sda ou /dev/nvme0).
|
||
/// Retourne None si smartctl est absent ou si la commande échoue.
|
||
pub fn collect() -> Option<SmartMetrics> {
|
||
if !is_available() {
|
||
return None;
|
||
}
|
||
// Essaie /dev/sda puis /dev/nvme0
|
||
for dev in &["/dev/sda", "/dev/nvme0"] {
|
||
let output = std::process::Command::new("smartctl")
|
||
.args(["-j", dev])
|
||
.output()
|
||
.ok()?;
|
||
let json = String::from_utf8_lossy(&output.stdout);
|
||
if let Ok(metrics) = parse_json(&json) {
|
||
return Some(metrics);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
```
|
||
|
||
- [ ] **Lancer les tests**
|
||
|
||
```bash
|
||
cd agent && rtk cargo test smart
|
||
```
|
||
|
||
Résultat attendu : `test_parse_smart_json_ok ... ok`, `test_parse_smart_json_fail ... ok`, `test_smart_disponible ... ok`
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
rtk git add agent/src/metrics/smart.rs agent/tests/smart_test.rs
|
||
rtk git commit -m "feat(agent): collecte SMART via smartctl"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7 : Transport UDP
|
||
|
||
**Files:**
|
||
- Create: `agent/src/transport/mod.rs`
|
||
- Create: `agent/src/transport/udp.rs`
|
||
|
||
- [ ] **Créer `agent/src/transport/mod.rs`**
|
||
|
||
```rust
|
||
pub mod mqtt;
|
||
pub mod udp;
|
||
```
|
||
|
||
- [ ] **Créer `agent/src/transport/udp.rs`**
|
||
|
||
```rust
|
||
use std::net::UdpSocket;
|
||
|
||
pub struct UdpSender {
|
||
socket: UdpSocket,
|
||
addr: String,
|
||
}
|
||
|
||
impl UdpSender {
|
||
pub fn new(ip: &str, port: u16) -> Self {
|
||
let socket = UdpSocket::bind("0.0.0.0:0")
|
||
.expect("Impossible de créer le socket UDP");
|
||
UdpSender {
|
||
socket,
|
||
addr: format!("{}:{}", ip, port),
|
||
}
|
||
}
|
||
|
||
/// Envoie le JSON via UDP. Fire-and-forget : ignore les erreurs réseau.
|
||
pub fn send(&self, json: &str) {
|
||
let _ = self.socket.send_to(json.as_bytes(), &self.addr);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::net::UdpSocket;
|
||
|
||
#[test]
|
||
fn test_udp_envoi_loopback() {
|
||
let recv = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||
let port = recv.local_addr().unwrap().port();
|
||
recv.set_read_timeout(Some(std::time::Duration::from_millis(500))).unwrap();
|
||
|
||
let sender = UdpSender::new("127.0.0.1", port);
|
||
sender.send(r#"{"status":"ok"}"#);
|
||
|
||
let mut buf = [0u8; 1024];
|
||
let (n, _) = recv.recv_from(&mut buf).unwrap();
|
||
let msg = std::str::from_utf8(&buf[..n]).unwrap();
|
||
assert_eq!(msg, r#"{"status":"ok"}"#);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Lancer le test**
|
||
|
||
```bash
|
||
cd agent && rtk cargo test transport::udp
|
||
```
|
||
|
||
Résultat attendu : `test test_udp_envoi_loopback ... ok`
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
rtk git add agent/src/transport/mod.rs agent/src/transport/udp.rs
|
||
rtk git commit -m "feat(agent): transport UDP + test loopback"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8 : Transport MQTT
|
||
|
||
**Files:**
|
||
- Create: `agent/src/transport/mqtt.rs`
|
||
|
||
- [ ] **Créer `agent/src/transport/mqtt.rs`**
|
||
|
||
```rust
|
||
use rumqttc::{Client, LastWill, MqttOptions, QoS, Event, Packet};
|
||
use std::sync::mpsc::Sender;
|
||
use std::time::Duration;
|
||
use crate::config::MqttConfig;
|
||
|
||
/// Message reçu depuis le broker (config update depuis serveur).
|
||
pub enum MqttIncoming {
|
||
ConfigUpdate(Vec<u8>),
|
||
}
|
||
|
||
/// Démarre le thread MQTT. Retourne le Client pour publier
|
||
/// et un Receiver pour recevoir les config updates.
|
||
pub fn start(
|
||
hostname: &str,
|
||
cfg: &MqttConfig,
|
||
incoming_tx: Sender<MqttIncoming>,
|
||
) -> Client {
|
||
let status_topic = format!("{}/{}/status", cfg.topic_base, hostname);
|
||
let config_topic = format!("{}/{}/config", cfg.topic_base, hostname);
|
||
|
||
let mut opts = MqttOptions::new(
|
||
format!("nanometrics-{}", hostname),
|
||
&cfg.host,
|
||
cfg.port,
|
||
);
|
||
opts.set_keep_alive(Duration::from_secs(30));
|
||
opts.set_connection_timeout(10);
|
||
|
||
if cfg.last_will {
|
||
opts.set_last_will(LastWill::new(
|
||
&status_topic,
|
||
"offline",
|
||
QoS::AtLeastOnce,
|
||
true,
|
||
));
|
||
}
|
||
|
||
let (client, mut connection) = Client::new(opts, 16);
|
||
|
||
// Thread de traitement des événements entrants
|
||
let client_clone = client.clone();
|
||
let config_topic_clone = config_topic.clone();
|
||
let status_topic_clone = status_topic.clone();
|
||
let birth = cfg.birth_message;
|
||
|
||
std::thread::spawn(move || {
|
||
for event in connection.iter() {
|
||
match event {
|
||
Ok(Event::Incoming(Packet::ConnAck(_))) => {
|
||
// Connexion établie : birth + subscribe config
|
||
if birth {
|
||
let _ = client_clone.publish(
|
||
&status_topic_clone,
|
||
QoS::AtLeastOnce,
|
||
true,
|
||
"online",
|
||
);
|
||
}
|
||
let _ = client_clone.subscribe(
|
||
&config_topic_clone,
|
||
QoS::AtLeastOnce,
|
||
);
|
||
}
|
||
Ok(Event::Incoming(Packet::Publish(p))) => {
|
||
if p.topic == config_topic_clone {
|
||
let _ = incoming_tx.send(MqttIncoming::ConfigUpdate(
|
||
p.payload.to_vec(),
|
||
));
|
||
}
|
||
}
|
||
Err(e) => {
|
||
eprintln!("[mqtt] erreur: {}", e);
|
||
std::thread::sleep(Duration::from_secs(5));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
});
|
||
|
||
client
|
||
}
|
||
|
||
/// Publie les métriques JSON sur `{topic_base}/{hostname}/metrics`.
|
||
pub fn publish_metrics(client: &Client, topic_base: &str, hostname: &str, json: &str) {
|
||
let topic = format!("{}/{}/metrics", topic_base, hostname);
|
||
let _ = client.publish(topic, QoS::AtMostOnce, false, json);
|
||
}
|
||
```
|
||
|
||
- [ ] **Vérifier la compilation**
|
||
|
||
```bash
|
||
cd agent && rtk cargo check
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
rtk git add agent/src/transport/mqtt.rs
|
||
rtk git commit -m "feat(agent): transport MQTT avec birth/LWT/subscribe config"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9 : Boucle principale
|
||
|
||
**Files:**
|
||
- Modify: `agent/src/main.rs`
|
||
|
||
- [ ] **Écrire `agent/src/main.rs` complet**
|
||
|
||
```rust
|
||
use nanometrics_agent::{config, metrics, payload, transport};
|
||
use sysinfo::{Components, Disks, Networks, System};
|
||
use std::time::{Duration, Instant};
|
||
use std::sync::mpsc;
|
||
|
||
fn get_local_ip() -> String {
|
||
// Heuristique : connexion UDP fictive pour trouver l'IP sortante
|
||
use std::net::UdpSocket;
|
||
let s = UdpSocket::bind("0.0.0.0:0").ok();
|
||
if let Some(sock) = s {
|
||
let _ = sock.connect("8.8.8.8:80");
|
||
if let Ok(addr) = sock.local_addr() {
|
||
return addr.ip().to_string();
|
||
}
|
||
}
|
||
"0.0.0.0".to_string()
|
||
}
|
||
|
||
fn apply_config_update(cfg: &mut config::Config, data: &[u8]) {
|
||
if let Ok(new_cfg) = serde_json::from_slice::<config::Config>(data) {
|
||
cfg.metrics = new_cfg.metrics;
|
||
eprintln!("[config] mis à jour depuis le serveur");
|
||
}
|
||
}
|
||
|
||
fn main() {
|
||
let cfg_path = std::env::args()
|
||
.nth(1)
|
||
.unwrap_or_else(|| "config.toml".to_string());
|
||
let mut cfg = config::load(std::path::Path::new(&cfg_path))
|
||
.expect("Impossible de charger config.toml");
|
||
|
||
let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string());
|
||
let ip = get_local_ip();
|
||
|
||
// Init sysinfo
|
||
let mut sys = System::new();
|
||
let mut networks = Networks::new_with_refreshed_list();
|
||
let mut disks = Disks::new_with_refreshed_list();
|
||
let mut components = Components::new_with_refreshed_list();
|
||
|
||
// Transport UDP
|
||
let udp_sender = if cfg.protocols.udp.enabled {
|
||
Some(transport::udp::UdpSender::new(&cfg.server.ip, cfg.server.port))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Transport MQTT
|
||
let (incoming_tx, incoming_rx) = mpsc::channel::<transport::mqtt::MqttIncoming>();
|
||
let mqtt_client = if cfg.protocols.mqtt.enabled {
|
||
Some(transport::mqtt::start(&hostname, &cfg.protocols.mqtt, incoming_tx))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Timers
|
||
let mut last_slow = Instant::now() - Duration::from_secs(61); // force au 1er tick
|
||
let mut last_medium = Instant::now() - Duration::from_secs(11);
|
||
|
||
loop {
|
||
let now = Instant::now();
|
||
|
||
// Traiter les config updates MQTT en attente
|
||
while let Ok(transport::mqtt::MqttIncoming::ConfigUpdate(data)) = incoming_rx.try_recv() {
|
||
apply_config_update(&mut cfg, &data);
|
||
}
|
||
|
||
// Refresh CPU + RAM (toujours)
|
||
sys.refresh_cpu_all();
|
||
sys.refresh_memory();
|
||
|
||
let mut m = payload::AgentMetrics {
|
||
hostname: hostname.clone(),
|
||
ip: ip.clone(),
|
||
status: "online".to_string(),
|
||
cpu_percent: Some(metrics::cpu::get(&sys)),
|
||
..Default::default()
|
||
};
|
||
|
||
let (mem_used, mem_free, mem_total) = metrics::memory::get(&sys);
|
||
m.memory_used = Some(mem_used);
|
||
m.memory_free = Some(mem_free);
|
||
m.memory_total = Some(mem_total);
|
||
|
||
// Refresh réseau + uptime (10s)
|
||
if now.duration_since(last_medium).as_secs() >= 10 {
|
||
networks.refresh();
|
||
components.refresh();
|
||
let (rx, tx) = metrics::network::get(&networks);
|
||
if cfg.metrics.network.udp || cfg.metrics.network.mqtt {
|
||
m.network_rx = Some(rx);
|
||
m.network_tx = Some(tx);
|
||
}
|
||
if cfg.metrics.uptime.udp || cfg.metrics.uptime.mqtt {
|
||
m.uptime = Some(metrics::uptime::get());
|
||
}
|
||
if cfg.metrics.temperature.udp || cfg.metrics.temperature.mqtt {
|
||
m.temperature = metrics::temperature::get(&components);
|
||
}
|
||
last_medium = now;
|
||
}
|
||
|
||
// Refresh disque + SMART (60s)
|
||
if now.duration_since(last_slow).as_secs() >= 60 {
|
||
disks.refresh();
|
||
if cfg.metrics.disk.udp || cfg.metrics.disk.mqtt {
|
||
let (used, free, total) = metrics::disk::get(&disks);
|
||
m.hdd_used = Some(used);
|
||
m.hdd_free = Some(free);
|
||
m.hdd_total = Some(total);
|
||
}
|
||
if cfg.metrics.smart.udp || cfg.metrics.smart.mqtt {
|
||
m.smart = metrics::smart::collect();
|
||
}
|
||
last_slow = now;
|
||
}
|
||
|
||
let json = serde_json::to_string(&m).expect("sérialisation JSON");
|
||
|
||
if let Some(ref udp) = udp_sender {
|
||
udp.send(&json);
|
||
}
|
||
|
||
if let (Some(ref client), true) = (&mqtt_client, cfg.protocols.mqtt.enabled) {
|
||
transport::mqtt::publish_metrics(client, &cfg.protocols.mqtt.topic_base, &hostname, &json);
|
||
}
|
||
|
||
std::thread::sleep(Duration::from_secs(2));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Compiler en mode release**
|
||
|
||
```bash
|
||
cd agent && rtk cargo build --release
|
||
```
|
||
|
||
Résultat attendu : binaire dans `target/release/nanometrics-agent`
|
||
|
||
- [ ] **Vérifier la taille du binaire (doit être < 3 Mo)**
|
||
|
||
```bash
|
||
ls -lh agent/target/release/nanometrics-agent
|
||
```
|
||
|
||
- [ ] **Lancer tous les tests**
|
||
|
||
```bash
|
||
cd agent && rtk cargo test
|
||
```
|
||
|
||
Résultat attendu : tous les tests `ok`
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
rtk git add agent/src/main.rs
|
||
rtk git commit -m "feat(agent): boucle principale avec timers différenciés"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10 : Service systemd
|
||
|
||
**Files:**
|
||
- Create: `deploy/nanometrics-agent.service`
|
||
- Create: `deploy/README.md`
|
||
|
||
- [ ] **Créer `deploy/nanometrics-agent.service`**
|
||
|
||
```ini
|
||
[Unit]
|
||
Description=Nanometrics Agent — collecte de métriques système
|
||
After=network.target
|
||
Documentation=https://github.com/user/nanometrics
|
||
|
||
[Service]
|
||
Type=simple
|
||
ExecStart=/usr/local/bin/nanometrics-agent /etc/nanometrics/config.toml
|
||
Restart=on-failure
|
||
RestartSec=5
|
||
|
||
# Sécurité : utilisateur éphémère minimal
|
||
DynamicUser=yes
|
||
ConfigurationDirectory=nanometrics
|
||
ConfigurationDirectoryMode=0750
|
||
|
||
# Confinement lecture/écriture
|
||
ProtectSystem=strict
|
||
ProtectHome=read-only
|
||
PrivateTmp=yes
|
||
NoNewPrivileges=yes
|
||
|
||
# Réseau autorisé (UDP + MQTT)
|
||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
```
|
||
|
||
- [ ] **Créer `deploy/README.md`**
|
||
|
||
```markdown
|
||
# Déploiement de l'agent
|
||
|
||
## Installation
|
||
|
||
```bash
|
||
# Copier le binaire
|
||
sudo cp ../agent/target/release/nanometrics-agent /usr/local/bin/
|
||
sudo chmod 755 /usr/local/bin/nanometrics-agent
|
||
|
||
# Créer la config
|
||
sudo mkdir -p /etc/nanometrics
|
||
sudo cp ../agent/config.toml.example /etc/nanometrics/config.toml
|
||
sudo nano /etc/nanometrics/config.toml # ajuster server.ip
|
||
|
||
# Installer le service
|
||
sudo cp nanometrics-agent.service /etc/systemd/system/
|
||
sudo systemctl daemon-reload
|
||
sudo systemctl enable nanometrics-agent
|
||
sudo systemctl start nanometrics-agent
|
||
sudo systemctl status nanometrics-agent
|
||
```
|
||
|
||
## Désinstallation
|
||
|
||
```bash
|
||
sudo systemctl stop nanometrics-agent
|
||
sudo systemctl disable nanometrics-agent
|
||
sudo rm /etc/systemd/system/nanometrics-agent.service
|
||
sudo rm /usr/local/bin/nanometrics-agent
|
||
```
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
rtk git add deploy/
|
||
rtk git commit -m "feat(agent): service systemd avec DynamicUser + confinement"
|
||
```
|
||
|
||
---
|
||
|
||
### Vérification finale
|
||
|
||
- [ ] **Lancer la suite de tests complète**
|
||
|
||
```bash
|
||
cd agent && rtk cargo test -- --test-threads=1
|
||
```
|
||
|
||
Résultat attendu : tous les tests `ok`, aucun `FAILED`
|
||
|
||
- [ ] **Clippy sans avertissements**
|
||
|
||
```bash
|
||
cd agent && rtk cargo clippy -- -D warnings
|
||
```
|
||
|
||
- [ ] **Tester manuellement avec le serveur Go** (une fois le serveur démarré)
|
||
|
||
```bash
|
||
# Depuis la machine agent
|
||
cd agent
|
||
cp config.toml.example config.toml
|
||
# Éditer config.toml : server.ip = IP_du_serveur
|
||
./target/release/nanometrics-agent config.toml
|
||
# Le serveur doit recevoir des paquets UDP toutes les 2s
|
||
```
|