Files
nano_metrics/docs/superpowers/plans/2026-05-22-nanometrics-agent-rust.md
T
Gilles Soulier a0f47bf966 feat: add plans, design system, CONSIGNE and brainstorm assets
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>
2026-05-22 08:13:53 +02:00

1352 lines
33 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.
# 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.0100.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
```