33 KiB
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.82"
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.82"
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.82");
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.82"
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
configcomme module public danslib.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.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
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.rscomplet
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