feat: add proxy and statistics features
This commit is contained in:
84
agent/Cargo.lock
generated
84
agent/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
242
agent/src/stats.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user