# 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> { 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, pub memory_used: Option, pub memory_free: Option, pub memory_total: Option, pub hdd_used: Option, pub hdd_free: Option, pub hdd_total: Option, pub uptime: Option, pub network_rx: Option, pub network_tx: Option, pub temperature: Option, pub smart: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SmartMetrics { pub passed: bool, pub temperature: Option, pub reallocated_sectors: Option, pub power_on_hours: Option, pub wear_level: Option, } ``` - [ ] **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 { 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, ata_smart_attributes: Option, nvme_smart_health_information_log: Option, } #[derive(Deserialize)] struct SmartStatus { passed: bool } #[derive(Deserialize)] struct SmartTemp { current: i64 } #[derive(Deserialize)] struct SmartAttrs { table: Vec } #[derive(Deserialize)] struct SmartAttr { id: u8, raw: SmartAttrRaw, value: Option, } #[derive(Deserialize)] struct SmartAttrRaw { value: i64 } #[derive(Deserialize)] struct NvmeHealth { percentage_used: Option, temperature: Option, } /// 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 { 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 { 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), } /// 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, ) -> 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::(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::(); 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 ```