feat: add proxy and statistics features

This commit is contained in:
2026-05-27 07:41:06 +02:00
parent 35643b2ea9
commit b3c7e67b78
23 changed files with 2139 additions and 147 deletions

84
agent/Cargo.lock generated
View File

@ -235,6 +235,7 @@ dependencies = [
"prost",
"serde",
"serde_json",
"sysinfo",
"tokio",
"tokio-stream",
"tonic",
@ -606,7 +607,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
"windows-core 0.62.2",
]
[[package]]
@ -866,6 +867,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@ -1449,6 +1459,19 @@ dependencies = [
"syn",
]
[[package]]
name = "sysinfo"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
dependencies = [
"core-foundation-sys",
"libc",
"memchr",
"ntapi",
"windows",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@ -1945,19 +1968,52 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link",
"windows-result",
"windows-result 0.4.1",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@ -1969,6 +2025,17 @@ dependencies = [
"syn",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@ -1986,6 +2053,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-result"
version = "0.4.1"

View File

@ -20,6 +20,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
anyhow = "1"
tokio-stream = "0.1"
futures-util = "0.3"
sysinfo = { version = "0.32", default-features = false, features = ["system", "network", "disk", "user"] }
[dev-dependencies]
# `bytes::Bytes` is used in MockBackend::logs() to construct LogOutput variants

View File

@ -241,6 +241,26 @@ impl ContainerBackend for DockerClient {
async fn list_networks(&self) -> Result<Vec<NetworkInfo>> {
const SYSTEM_NETWORKS: &[&str] = &["bridge", "host", "none"];
// Cross-reference containers to find which networks are actually in use.
// NOTE: GET /networks never populates the `containers` field — only
// individual inspect calls do — so we must build the used-set ourselves.
let containers = self
.inner
.list_containers(Some(ListContainersOptions::<String> {
all: true,
..Default::default()
}))
.await
.unwrap_or_default();
let used_network_ids: std::collections::HashSet<String> = containers
.iter()
.filter_map(|c| c.network_settings.as_ref())
.filter_map(|ns| ns.networks.as_ref())
.flat_map(|nets| nets.values())
.filter_map(|ep| ep.network_id.clone())
.collect();
let networks = self
.inner
.list_networks(None::<ListNetworksOptions<String>>)
@ -250,17 +270,11 @@ impl ContainerBackend for DockerClient {
.into_iter()
.map(|n| {
let name = n.name.clone().unwrap_or_default();
let id = n.id.clone().unwrap_or_default();
let is_system = SYSTEM_NETWORKS.contains(&name.as_str());
// A network is orphan if it is not a system network and has no
// containers connected to it (the `containers` map is absent or empty).
let has_containers = n
.containers
.as_ref()
.map(|m| !m.is_empty())
.unwrap_or(false);
let is_orphan = !is_system && !has_containers;
let is_orphan = !is_system && !used_network_ids.contains(&id);
NetworkInfo {
id: n.id.unwrap_or_default(),
id,
name,
driver: n.driver.unwrap_or_default(),
scope: n.scope.unwrap_or_default(),
@ -559,6 +573,14 @@ pub mod tests {
assert_eq!(networks[0].name, "bridge");
}
#[tokio::test]
async fn mock_list_networks_bridge_is_not_orphan() {
let backend = MockBackend::new();
let networks = backend.list_networks().await.unwrap();
// "bridge" is a system network and must never be flagged as orphan.
assert!(!networks[0].is_orphan, "system network 'bridge' must not be orphan");
}
#[tokio::test]
async fn mock_list_networks_records_call() {
let backend = MockBackend::new();

View File

@ -1,4 +1,5 @@
mod docker;
mod stats;
pub mod proto {
tonic::include_proto!("containarr.agent.v1");
@ -11,7 +12,7 @@ use bollard::container::{
};
use bollard::network::ConnectNetworkOptions;
use bollard::models::EndpointSettings;
use bollard::image::CreateImageOptions;
use bollard::image::{CreateImageOptions, RemoveImageOptions};
use docker::{ContainerBackend, DockerClient};
use futures_util::StreamExt as _;
use proto::{
@ -21,6 +22,8 @@ use proto::{
FileResult, ImageInfo, VolumeInfo, NetworkInfo,
UpdateCheckResult,
};
use stats::PrevNetStats;
use sysinfo::System;
use serde::Serialize;
use std::{collections::HashMap, env, time::Duration};
use tokio::{sync::mpsc, task::JoinHandle, time};
@ -80,6 +83,12 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re
let mut snapshot_ticker = time::interval(SNAPSHOT_INTERVAL);
let mut log_tasks: HashMap<String, JoinHandle<()>> = HashMap::new();
// System is kept alive between ticks so CPU usage reflects a real delta
let mut sys = System::new_all();
// Initial refresh to establish baseline for the first CPU delta
sys.refresh_all();
let mut prev_net = PrevNetStats::new();
loop {
tokio::select! {
_ = snapshot_ticker.tick() => {
@ -123,6 +132,16 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re
if tx.send(msg).await.is_err() {
break;
}
// Collect and send system stats snapshot
let stats_snapshot = stats::collect_stats(&mut sys, &mut prev_net);
let stats_msg = AgentMessage {
payload: Some(agent_message::Payload::StatsSnapshot(stats_snapshot)),
};
if let Err(e) = tx.send(stats_msg).await {
warn!("failed to send StatsSnapshot: {:#}", e);
break;
}
}
result = inbound.message() => {
@ -640,6 +659,9 @@ async fn standalone_recreate(
.unwrap_or_default()
};
// Récupérer l'ID de l'ancienne image avant le pull
let old_image_id: Option<String> = inspect.image.clone();
// 1. Pull the new image
pull_image_and_detect_update(docker, &image_name).await?;
@ -708,6 +730,22 @@ async fn standalone_recreate(
}
info!("container {container_name} recreated with new image {image_name}");
// Supprimer l'ancienne image si elle est devenue orpheline (best-effort)
if let Some(old_id) = old_image_id {
match docker
.remove_image(
&old_id,
Some(RemoveImageOptions { force: false, noprune: false }),
None,
)
.await
{
Ok(_) => info!("cleaned up old image {old_id} after update"),
Err(e) => info!("old image {old_id} not removed (still in use or already gone): {e}"),
}
}
Ok(())
}

242
agent/src/stats.rs Normal file
View File

@ -0,0 +1,242 @@
use std::time::{SystemTime, UNIX_EPOCH};
use sysinfo::{
CpuRefreshKind, Disks, MemoryRefreshKind, Networks, ProcessRefreshKind,
RefreshKind, System,
};
use crate::proto::{DiskStats, NetworkInterfaceStats, ProcessInfo, StatsSnapshot};
/// Stores previous network counters to compute per-snapshot rates.
pub struct PrevNetStats {
/// Map of interface name → (bytes_recv, bytes_sent)
pub counters: std::collections::HashMap<String, (u64, u64)>,
/// Unix timestamp (secs) of last snapshot
pub timestamp: u64,
}
impl PrevNetStats {
pub fn new() -> Self {
Self {
counters: std::collections::HashMap::new(),
timestamp: unix_secs(),
}
}
}
fn unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Collect a full system stats snapshot.
///
/// `sys` must be kept alive between calls so that CPU usage reflects a real
/// delta (sysinfo computes CPU% as difference between two refreshes).
pub fn collect_stats(sys: &mut System, prev_net: &mut PrevNetStats) -> StatsSnapshot {
// Refresh CPU and memory
sys.refresh_specifics(
RefreshKind::new()
.with_cpu(CpuRefreshKind::new().with_cpu_usage())
.with_memory(MemoryRefreshKind::new().with_ram())
.with_processes(ProcessRefreshKind::new().with_cpu().with_memory()),
);
// ── CPU ───────────────────────────────────────────────────────────────────
let cpu_pct = sys.global_cpu_usage() as f64;
let cpu_per_core: Vec<f64> = sys.cpus().iter().map(|c| c.cpu_usage() as f64).collect();
// ── Memory ────────────────────────────────────────────────────────────────
let mem_total = sys.total_memory();
let mem_used = sys.used_memory();
let mem_available = sys.available_memory();
// ── Processes — top 20 by CPU usage ──────────────────────────────────────
let mut proc_list: Vec<ProcessInfo> = sys
.processes()
.values()
.map(|p| {
let cmd = p
.cmd()
.iter()
.map(|s| s.to_string_lossy())
.collect::<Vec<_>>()
.join(" ");
ProcessInfo {
pid: p.pid().as_u32(),
name: p.name().to_string_lossy().to_string(),
cmd,
cpu_pct: p.cpu_usage() as f64,
mem_rss: p.memory(),
}
})
.collect();
proc_list.sort_by(|a, b| b.cpu_pct.partial_cmp(&a.cpu_pct).unwrap_or(std::cmp::Ordering::Equal));
proc_list.truncate(20);
// ── Networks ─────────────────────────────────────────────────────────────
let mut networks = Networks::new_with_refreshed_list();
networks.refresh();
let now_secs = unix_secs();
let elapsed = now_secs.saturating_sub(prev_net.timestamp).max(1);
let net_interfaces: Vec<NetworkInterfaceStats> = networks
.iter()
.map(|(name, data)| {
let recv = data.total_received();
let sent = data.total_transmitted();
let (prev_recv, prev_sent) = prev_net
.counters
.get(name.as_str())
.copied()
.unwrap_or((recv, sent));
let recv_rate = recv.saturating_sub(prev_recv) / elapsed;
let sent_rate = sent.saturating_sub(prev_sent) / elapsed;
NetworkInterfaceStats {
name: name.to_string(),
bytes_recv: recv,
bytes_sent: sent,
bytes_recv_rate: recv_rate,
bytes_sent_rate: sent_rate,
}
})
.collect();
// Update previous counters
prev_net.counters.clear();
for (name, data) in networks.iter() {
prev_net
.counters
.insert(name.to_string(), (data.total_received(), data.total_transmitted()));
}
prev_net.timestamp = now_secs;
// ── Disks ─────────────────────────────────────────────────────────────────
let mut disk_list = Disks::new_with_refreshed_list();
disk_list.refresh();
let disks: Vec<DiskStats> = disk_list
.iter()
.filter(|d| !d.is_removable())
.map(|d| {
let total = d.total_space();
let free = d.available_space();
let used = total.saturating_sub(free);
DiskStats {
path: d.mount_point().to_string_lossy().to_string(),
total,
used,
free,
}
})
.collect();
StatsSnapshot {
cpu_pct,
cpu_per_core,
mem_total,
mem_used,
mem_available,
net_interfaces,
processes: proc_list,
disks,
timestamp: now_secs as i64,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prev_net_stats_initializes_empty() {
let prev = PrevNetStats::new();
assert!(prev.counters.is_empty());
assert!(prev.timestamp > 0);
}
#[test]
fn collect_stats_returns_plausible_values() {
let mut sys = System::new_all();
// First refresh to establish baseline for CPU delta
sys.refresh_all();
let mut prev = PrevNetStats::new();
let snapshot = collect_stats(&mut sys, &mut prev);
// CPU should be a percentage 0-100 (per core can spike > 100 on some OSes, skip that)
assert!(snapshot.cpu_pct >= 0.0 && snapshot.cpu_pct <= 100.0 * sys.cpus().len() as f64 + 1.0);
// Memory values must be non-zero and consistent
assert!(snapshot.mem_total > 0);
assert!(snapshot.mem_used <= snapshot.mem_total);
// Timestamp should be recent (after year 2020)
assert!(snapshot.timestamp > 1_577_836_800);
// Should have at least one CPU core reported
assert!(!snapshot.cpu_per_core.is_empty());
}
#[test]
fn collect_stats_updates_prev_net_counters() {
let mut sys = System::new_all();
sys.refresh_all();
let mut prev = PrevNetStats::new();
collect_stats(&mut sys, &mut prev);
// After first call, prev counters should be populated
// (may be empty if no network interfaces exist in test env, but timestamp must update)
assert!(prev.timestamp > 0);
}
#[test]
fn rate_calculation_does_not_overflow() {
// Simulate a case where prev > current (counter reset), saturating_sub handles it
let prev_recv: u64 = 1_000_000;
let current_recv: u64 = 500; // counter wrapped/reset
let elapsed: u64 = 10;
let rate = current_recv.saturating_sub(prev_recv) / elapsed;
assert_eq!(rate, 0); // saturating_sub returns 0 on underflow
}
#[test]
fn collect_stats_disks_are_non_removable() {
let mut sys = System::new_all();
sys.refresh_all();
let mut prev = PrevNetStats::new();
let snapshot = collect_stats(&mut sys, &mut prev);
// All disks in snapshot must have a non-empty mount point
for disk in &snapshot.disks {
assert!(!disk.path.is_empty());
assert!(disk.total >= disk.used);
}
}
#[test]
fn collect_stats_processes_top20() {
let mut sys = System::new_all();
sys.refresh_all();
let mut prev = PrevNetStats::new();
let snapshot = collect_stats(&mut sys, &mut prev);
// At most 20 processes
assert!(snapshot.processes.len() <= 20);
// Processes are sorted by cpu_pct descending
let cpus: Vec<f64> = snapshot.processes.iter().map(|p| p.cpu_pct).collect();
for i in 1..cpus.len() {
assert!(cpus[i - 1] >= cpus[i], "processes not sorted by cpu_pct desc");
}
}
}