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

33 KiB
Raw Blame History

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

[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
[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)
mod config;
mod metrics;
mod payload;
mod transport;

fn main() {
    println!("nanometrics-agent starting");
}
  • Vérifier que ça compile
cd agent && rtk cargo check

Résultat attendu : Checking nanometrics-agent v0.1.0 sans erreurs.

  • Commit
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 :

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)
cd agent && rtk cargo test

Résultat attendu : error[E0432]: unresolved import

  • Créer agent/src/config.rs
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 :

pub mod config;
pub mod metrics;
pub mod payload;
pub mod transport;

Modifier agent/src/main.rs :

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
cd agent && rtk cargo test config

Résultat attendu : test test_config_parse_complet ... ok et test test_config_mqtt_absent ... ok

  • Commit
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 :

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
cd agent && rtk cargo test payload
  • Créer agent/src/payload.rs
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
cd agent && rtk cargo test payload
  • Commit
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

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
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
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
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
/// 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
cd agent && rtk cargo test metrics

Résultat attendu : tous les tests ok.

  • Commit
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

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
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
cd agent && rtk cargo test disk
  • Commit
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 :

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
cd agent && rtk cargo test smart
  • Créer agent/src/metrics/smart.rs
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
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
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

pub mod mqtt;
pub mod udp;
  • Créer agent/src/transport/udp.rs
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
cd agent && rtk cargo test transport::udp

Résultat attendu : test test_udp_envoi_loopback ... ok

  • Commit
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

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
cd agent && rtk cargo check
  • Commit
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

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
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)
ls -lh agent/target/release/nanometrics-agent
  • Lancer tous les tests
cd agent && rtk cargo test

Résultat attendu : tous les tests ok

  • Commit
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

[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
# 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

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
cd agent && rtk cargo test -- --test-threads=1

Résultat attendu : tous les tests ok, aucun FAILED

  • Clippy sans avertissements
cd agent && rtk cargo clippy -- -D warnings
  • Tester manuellement avec le serveur Go (une fois le serveur démarré)
# 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