huskies: merge 1207 story fleet_resources MCP tool: on-demand host + per-container disk, load, and CPU/mem
This commit is contained in:
@@ -29,6 +29,8 @@ const GATEWAY_TOOLS: &[&str] = &[
|
|||||||
"prompt_permission",
|
"prompt_permission",
|
||||||
// One-shot container rebuild: build fresh image, swap container, preserve state.
|
// One-shot container rebuild: build fresh image, swap container, preserve state.
|
||||||
"project_rebuild",
|
"project_rebuild",
|
||||||
|
// On-demand host + per-container disk/load/CPU/mem snapshot (story 1207).
|
||||||
|
"fleet_resources",
|
||||||
// Read sled identity pins vs. live signed identity, and TOFU re-pin.
|
// Read sled identity pins vs. live signed identity, and TOFU re-pin.
|
||||||
"fleet_identity",
|
"fleet_identity",
|
||||||
];
|
];
|
||||||
@@ -156,6 +158,31 @@ pub(crate) fn gateway_tool_definitions() -> Vec<Value> {
|
|||||||
"required": ["name"]
|
"required": ["name"]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "fleet_resources",
|
||||||
|
"description": "On-demand host and per-container resource snapshot: host disk free/total, load averages, core count, and memory; per-container CPU%/mem sourced via `docker stats`; and per-project `target/`+`.huskies/worktrees/` directory sizes sourced via `docker exec ... find` (bounded, skips node_modules/.git, cached briefly per container+path). Flags disk/load conditions past thresholds first so problems lead the response.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"disk_warn_gb": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Host free-disk warn threshold in GB (default: 50)."
|
||||||
|
},
|
||||||
|
"disk_critical_gb": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Host free-disk critical threshold in GB (default: 20)."
|
||||||
|
},
|
||||||
|
"load_warn_per_core": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "1-minute load average per core above which a warn flag fires (default: 1.0)."
|
||||||
|
},
|
||||||
|
"load_critical_per_core": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "1-minute load average per core above which a critical flag fires (default: 2.0)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
json!({
|
json!({
|
||||||
"name": "fleet_identity",
|
"name": "fleet_identity",
|
||||||
"description": "Read mode (default): for every registered sled, report project, url, connected, the recorded pin (expected_node_id), the live signature-verified node_id from a signed challenge-response (never the unsigned /identity display field), and whether they match. Repin mode: capture a sled's live verified identity via TOFU and persist it as the new pin, refusing when the signature is missing or does not verify.",
|
"description": "Read mode (default): for every registered sled, report project, url, connected, the recorded pin (expected_node_id), the live signature-verified node_id from a signed challenge-response (never the unsigned /identity display field), and whether they match. Repin mode: capture a sled's live verified identity via TOFU and persist it as the new pin, refusing when the signature is missing or does not verify.",
|
||||||
@@ -440,6 +467,7 @@ async fn handle_gateway_tool(
|
|||||||
"agents.list" => handle_agents_list_tool(id),
|
"agents.list" => handle_agents_list_tool(id),
|
||||||
"prompt_permission" => handle_prompt_permission_tool(params, state, id).await,
|
"prompt_permission" => handle_prompt_permission_tool(params, state, id).await,
|
||||||
"project_rebuild" => handle_project_rebuild_tool(params, state, id).await,
|
"project_rebuild" => handle_project_rebuild_tool(params, state, id).await,
|
||||||
|
"fleet_resources" => handle_fleet_resources_tool(params, state, id).await,
|
||||||
"fleet_identity" => handle_fleet_identity_tool(params, state, id).await,
|
"fleet_identity" => handle_fleet_identity_tool(params, state, id).await,
|
||||||
_ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")),
|
_ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")),
|
||||||
}
|
}
|
||||||
@@ -943,6 +971,65 @@ async fn handle_project_rebuild_tool(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle the `fleet_resources` gateway tool (story 1207).
|
||||||
|
///
|
||||||
|
/// Collects host disk/load/cpu/mem, per-container CPU%/mem (via `docker
|
||||||
|
/// stats`), and per-project `target/`/`worktrees/` sizes (via `docker exec
|
||||||
|
/// ... find`) — all sourced gateway-side rather than requiring each project's
|
||||||
|
/// sled to self-report. Thresholds for the leading `flags` list are supplied
|
||||||
|
/// as optional arguments (see `gateway_tool_definitions`), defaulting to
|
||||||
|
/// `ResourceThresholds::default()`.
|
||||||
|
async fn handle_fleet_resources_tool(
|
||||||
|
params: &Value,
|
||||||
|
state: &GatewayState,
|
||||||
|
id: Option<Value>,
|
||||||
|
) -> JsonRpcResponse {
|
||||||
|
use crate::service::gateway::resources::ResourceThresholds;
|
||||||
|
|
||||||
|
let args = params.get("arguments").unwrap_or(params);
|
||||||
|
let defaults = ResourceThresholds::default();
|
||||||
|
let thresholds = ResourceThresholds {
|
||||||
|
disk_warn_gb: args
|
||||||
|
.get("disk_warn_gb")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(defaults.disk_warn_gb),
|
||||||
|
disk_critical_gb: args
|
||||||
|
.get("disk_critical_gb")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(defaults.disk_critical_gb),
|
||||||
|
load_warn_per_core: args
|
||||||
|
.get("load_warn_per_core")
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(defaults.load_warn_per_core),
|
||||||
|
load_critical_per_core: args
|
||||||
|
.get("load_critical_per_core")
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(defaults.load_critical_per_core),
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_names: Vec<String> = state.projects.read().await.keys().cloned().collect();
|
||||||
|
|
||||||
|
match crate::service::gateway::resources::io::collect_fleet_resources(
|
||||||
|
&state.config_dir,
|
||||||
|
&project_names,
|
||||||
|
&thresholds,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resources) => JsonRpcResponse::success(
|
||||||
|
id,
|
||||||
|
json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": serde_json::to_string_pretty(&resources).unwrap_or_default()
|
||||||
|
}],
|
||||||
|
"resources": serde_json::to_value(&resources).unwrap_or(json!(null)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Err(e) => JsonRpcResponse::error(id, -32603, format!("fleet_resources failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle the `fleet_identity` gateway tool.
|
/// Handle the `fleet_identity` gateway tool.
|
||||||
///
|
///
|
||||||
/// Dispatches on the `action` argument: `"read"` (default) reports every
|
/// Dispatches on the `action` argument: `"read"` (default) reports every
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub(crate) mod io;
|
|||||||
pub mod polling;
|
pub mod polling;
|
||||||
/// Pure signed release-manifest verification (signature, sha256, rollback) — no I/O.
|
/// Pure signed release-manifest verification (signature, sha256, rollback) — no I/O.
|
||||||
pub mod release_manifest;
|
pub mod release_manifest;
|
||||||
|
/// Fleet resource types and collection (`fleet_resources` MCP tool, story 1207).
|
||||||
|
pub mod resources;
|
||||||
|
|
||||||
pub use aggregation::{
|
pub use aggregation::{
|
||||||
find_project_containing_story, format_aggregate_status_compact, format_identity_reports,
|
find_project_containing_story, format_aggregate_status_compact, format_identity_reports,
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
//! Side effects for `fleet_resources` (story 1207): host stat reads (statvfs,
|
||||||
|
//! getloadavg, `/proc/meminfo`), `docker stats`/`docker exec` subprocess
|
||||||
|
//! calls, and a short-TTL cache for the `docker exec ... find` directory-size
|
||||||
|
//! probe.
|
||||||
|
//!
|
||||||
|
//! Host stat reads are genuinely blocking syscalls/file reads and run inside
|
||||||
|
//! `spawn_blocking`. `docker` subprocess calls use `tokio::process::Command`,
|
||||||
|
//! which is already async and non-blocking — wrapping an awaited async call
|
||||||
|
//! in `spawn_blocking` would be a no-op, so those are left as plain `.await`s
|
||||||
|
//! (matching the existing convention in `chat::transport::matrix::project_rebuild`).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::unix::ffi::OsStrExt as _;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use futures::future::Future;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
ContainerStats, HostStats, ProjectDirSizes, parse_docker_cpu_percent, parse_docker_mem_usage,
|
||||||
|
parse_meminfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Host stats ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read free/total bytes on the filesystem containing `path` via `statvfs(2)`.
|
||||||
|
fn disk_stats(path: &Path) -> std::io::Result<(u64, u64)> {
|
||||||
|
let c_path = CString::new(path.as_os_str().as_bytes())
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
|
||||||
|
// SAFETY: `c_path` is a valid NUL-terminated string and `stat` is a
|
||||||
|
// zero-initialized `libc::statvfs` passed by mutable reference, matching
|
||||||
|
// the `statvfs(2)` contract.
|
||||||
|
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||||
|
let ret = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
|
||||||
|
if ret != 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let free = stat.f_bavail as u64 * stat.f_frsize as u64;
|
||||||
|
let total = stat.f_blocks as u64 * stat.f_frsize as u64;
|
||||||
|
Ok((free, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read 1/5/15-minute load averages via `getloadavg(3)`.
|
||||||
|
fn load_averages() -> Option<(f64, f64, f64)> {
|
||||||
|
let mut loads = [0f64; 3];
|
||||||
|
// SAFETY: `loads` has room for 3 entries, matching the `3` passed in.
|
||||||
|
let n = unsafe { libc::getloadavg(loads.as_mut_ptr(), 3) };
|
||||||
|
if n == 3 {
|
||||||
|
Some((loads[0], loads[1], loads[2]))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `MemTotal`/`MemAvailable` from `/proc/meminfo`.
|
||||||
|
fn mem_stats() -> Option<(u64, u64)> {
|
||||||
|
let contents = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||||
|
parse_meminfo(&contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all host-level stats. Runs on `spawn_blocking` since `statvfs`,
|
||||||
|
/// `getloadavg`, and the `/proc/meminfo` read are all blocking calls.
|
||||||
|
pub async fn collect_host_stats(workspace_root: &Path) -> Result<HostStats, String> {
|
||||||
|
let root = workspace_root.to_path_buf();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let (disk_free_bytes, disk_total_bytes) = disk_stats(&root).map_err(|e| e.to_string())?;
|
||||||
|
let (load_avg_1, load_avg_5, load_avg_15) = load_averages().unwrap_or((0.0, 0.0, 0.0));
|
||||||
|
let cpu_cores = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(1);
|
||||||
|
let (mem_total_bytes, mem_available_bytes) = mem_stats().unwrap_or((0, 0));
|
||||||
|
Ok(HostStats {
|
||||||
|
disk_free_bytes,
|
||||||
|
disk_total_bytes,
|
||||||
|
load_avg_1,
|
||||||
|
load_avg_5,
|
||||||
|
load_avg_15,
|
||||||
|
cpu_cores,
|
||||||
|
mem_total_bytes,
|
||||||
|
mem_available_bytes,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking panicked: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Docker container stats ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The Docker container name for a registered project, following the
|
||||||
|
/// convention established by `project_rebuild.rs`.
|
||||||
|
fn container_name(project: &str) -> String {
|
||||||
|
format!("huskies-{project}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query `docker stats --no-stream` for one container's CPU%/mem usage.
|
||||||
|
/// Tolerates failure (missing/stopped container, no Docker) by returning a
|
||||||
|
/// `ContainerStats` with `error` set rather than propagating an error.
|
||||||
|
async fn docker_stats_one(project: &str) -> ContainerStats {
|
||||||
|
let container = container_name(project);
|
||||||
|
let output = tokio::process::Command::new("docker")
|
||||||
|
.args([
|
||||||
|
"stats",
|
||||||
|
"--no-stream",
|
||||||
|
"--format",
|
||||||
|
"{{.CPUPerc}}\t{{.MemUsage}}",
|
||||||
|
&container,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let line = text.lines().next().unwrap_or("");
|
||||||
|
let mut parts = line.splitn(2, '\t');
|
||||||
|
let cpu_percent = parts.next().and_then(parse_docker_cpu_percent);
|
||||||
|
let mem = parts.next().and_then(parse_docker_mem_usage);
|
||||||
|
ContainerStats {
|
||||||
|
project: project.to_string(),
|
||||||
|
container,
|
||||||
|
cpu_percent,
|
||||||
|
mem_usage_bytes: mem.map(|(used, _)| used),
|
||||||
|
mem_limit_bytes: mem.map(|(_, limit)| limit),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out) => ContainerStats {
|
||||||
|
project: project.to_string(),
|
||||||
|
container,
|
||||||
|
cpu_percent: None,
|
||||||
|
mem_usage_bytes: None,
|
||||||
|
mem_limit_bytes: None,
|
||||||
|
error: Some(String::from_utf8_lossy(&out.stderr).trim().to_string()),
|
||||||
|
},
|
||||||
|
Err(e) => ContainerStats {
|
||||||
|
project: project.to_string(),
|
||||||
|
container,
|
||||||
|
cpu_percent: None,
|
||||||
|
mem_usage_bytes: None,
|
||||||
|
mem_limit_bytes: None,
|
||||||
|
error: Some(format!("docker stats failed to spawn: {e}")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query `docker stats` for every project's container concurrently,
|
||||||
|
/// tolerating per-container failures (mirrors `gateway_health`'s per-project
|
||||||
|
/// tolerance) so one dead container never fails the whole call.
|
||||||
|
pub async fn collect_container_stats(project_names: &[String]) -> Vec<ContainerStats> {
|
||||||
|
use futures::future::join_all;
|
||||||
|
join_all(project_names.iter().map(|name| docker_stats_one(name))).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bounded `du` via `docker exec ... find`, TTL-cached ─────────────────
|
||||||
|
|
||||||
|
/// How long a computed directory size is reused before being recomputed.
|
||||||
|
/// Bounds repeated `fleet_resources` calls in a short window from re-shelling
|
||||||
|
/// out to `docker exec ... find` on every call (story 1207 AC4).
|
||||||
|
const DU_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Memoize an async, fallible value producer for [`DU_CACHE_TTL`], keyed by
|
||||||
|
/// `key`. `compute` is only invoked on a cache miss/expiry — errors are
|
||||||
|
/// returned directly without populating the cache, so a transient failure
|
||||||
|
/// doesn't poison later calls for [`DU_CACHE_TTL`].
|
||||||
|
///
|
||||||
|
/// Generic over the producer so it can be exercised in tests with a mocked
|
||||||
|
/// closure (no Docker required) while real callers pass a `docker exec`
|
||||||
|
/// invocation.
|
||||||
|
async fn cached_or_compute<F, Fut>(key: String, compute: F) -> Result<u64, String>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: Future<Output = Result<u64, String>>,
|
||||||
|
{
|
||||||
|
fn cache() -> &'static Mutex<HashMap<String, (Instant, u64)>> {
|
||||||
|
static CACHE: OnceLock<Mutex<HashMap<String, (Instant, u64)>>> = OnceLock::new();
|
||||||
|
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((at, bytes)) = cache().lock().unwrap().get(&key)
|
||||||
|
&& at.elapsed() < DU_CACHE_TTL
|
||||||
|
{
|
||||||
|
return Ok(*bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = compute().await?;
|
||||||
|
cache().lock().unwrap().insert(key, (Instant::now(), bytes));
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum file sizes under `path_in_container` (inside the named container) via
|
||||||
|
/// `docker exec <container> find`, skipping `node_modules`/`.git` entirely so
|
||||||
|
/// the walk stays bounded on large worktree trees. Returns `0` (rather than
|
||||||
|
/// erroring) when the path doesn't exist in the container — `find` reports
|
||||||
|
/// that on stderr with a non-zero exit but partial/empty stdout is still
|
||||||
|
/// valid, matching `dir_size_bytes`'s "missing path returns 0" contract.
|
||||||
|
async fn du_via_docker_exec(container: &str, path_in_container: &str) -> Result<u64, String> {
|
||||||
|
let output = tokio::process::Command::new("docker")
|
||||||
|
.args([
|
||||||
|
"exec",
|
||||||
|
container,
|
||||||
|
"find",
|
||||||
|
path_in_container,
|
||||||
|
"-type",
|
||||||
|
"f",
|
||||||
|
"-not",
|
||||||
|
"-path",
|
||||||
|
"*/node_modules/*",
|
||||||
|
"-not",
|
||||||
|
"-path",
|
||||||
|
"*/.git/*",
|
||||||
|
"-printf",
|
||||||
|
"%s\n",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("docker exec failed to spawn: {e}"))?;
|
||||||
|
|
||||||
|
let bytes = String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| l.trim().parse::<u64>().ok())
|
||||||
|
.sum();
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute `target/` and `.huskies/worktrees/` sizes for one project's
|
||||||
|
/// container, each cached independently for [`DU_CACHE_TTL`].
|
||||||
|
async fn project_dir_sizes_one(project: &str) -> ProjectDirSizes {
|
||||||
|
let container = container_name(project);
|
||||||
|
|
||||||
|
let target_key = format!("{container}:/workspace/target");
|
||||||
|
let target_container = container.clone();
|
||||||
|
let target_result = cached_or_compute(target_key, || async move {
|
||||||
|
du_via_docker_exec(&target_container, "/workspace/target").await
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let worktrees_key = format!("{container}:/workspace/.huskies/worktrees");
|
||||||
|
let worktrees_container = container.clone();
|
||||||
|
let worktrees_result = cached_or_compute(worktrees_key, || async move {
|
||||||
|
du_via_docker_exec(&worktrees_container, "/workspace/.huskies/worktrees").await
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match (target_result, worktrees_result) {
|
||||||
|
(Ok(target_bytes), Ok(worktrees_bytes)) => ProjectDirSizes {
|
||||||
|
project: project.to_string(),
|
||||||
|
target_bytes: Some(target_bytes),
|
||||||
|
worktrees_bytes: Some(worktrees_bytes),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
(Err(e), _) | (_, Err(e)) => ProjectDirSizes {
|
||||||
|
project: project.to_string(),
|
||||||
|
target_bytes: None,
|
||||||
|
worktrees_bytes: None,
|
||||||
|
error: Some(e),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute per-project directory sizes for every project concurrently,
|
||||||
|
/// tolerating per-project failures.
|
||||||
|
pub async fn collect_project_dir_sizes(project_names: &[String]) -> Vec<ProjectDirSizes> {
|
||||||
|
use futures::future::join_all;
|
||||||
|
join_all(project_names.iter().map(|name| project_dir_sizes_one(name))).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orchestration ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Collect the full `fleet_resources` response: host stats, per-container
|
||||||
|
/// CPU/mem, per-project directory sizes, and threshold flags.
|
||||||
|
///
|
||||||
|
/// `workspace_root` is the gateway's own workspace path (used for host disk
|
||||||
|
/// stats); `project_names` are the currently registered `projects.toml` keys.
|
||||||
|
pub async fn collect_fleet_resources(
|
||||||
|
workspace_root: &Path,
|
||||||
|
project_names: &[String],
|
||||||
|
thresholds: &super::ResourceThresholds,
|
||||||
|
) -> Result<super::FleetResources, String> {
|
||||||
|
let host = collect_host_stats(workspace_root).await?;
|
||||||
|
let (containers, projects) = tokio::join!(
|
||||||
|
collect_container_stats(project_names),
|
||||||
|
collect_project_dir_sizes(project_names),
|
||||||
|
);
|
||||||
|
let flags = super::flag_host(&host, thresholds);
|
||||||
|
|
||||||
|
Ok(super::FleetResources {
|
||||||
|
flags,
|
||||||
|
host,
|
||||||
|
containers,
|
||||||
|
projects,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
// ── collect_host_stats ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn collect_host_stats_reads_real_host() {
|
||||||
|
let stats = collect_host_stats(Path::new(".")).await.unwrap();
|
||||||
|
assert!(stats.disk_total_bytes > 0);
|
||||||
|
assert!(stats.disk_free_bytes <= stats.disk_total_bytes);
|
||||||
|
assert!(stats.cpu_cores >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn collect_host_stats_missing_path_is_error() {
|
||||||
|
let result = collect_host_stats(Path::new("/no/such/path/1207")).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cached_or_compute (mocked producer, no Docker needed) ───────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cached_or_compute_calls_producer_once_within_ttl() {
|
||||||
|
let calls = std::sync::Arc::new(AtomicUsize::new(0));
|
||||||
|
let key = "test-key-1".to_string();
|
||||||
|
|
||||||
|
let make_producer = |calls: std::sync::Arc<AtomicUsize>, value: u64| {
|
||||||
|
move || {
|
||||||
|
let calls = calls.clone();
|
||||||
|
async move {
|
||||||
|
calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = cached_or_compute(key.clone(), make_producer(calls.clone(), 42))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let second = cached_or_compute(key.clone(), make_producer(calls.clone(), 99))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(first, 42);
|
||||||
|
assert_eq!(
|
||||||
|
second, 42,
|
||||||
|
"second call within TTL must reuse the cached value"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
calls.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"producer must only run once while the cache entry is still fresh — proves the \
|
||||||
|
expensive computation itself (not just a pre-computed value) is skipped on a hit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cached_or_compute_different_keys_do_not_share_a_cache_entry() {
|
||||||
|
let calls = std::sync::Arc::new(AtomicUsize::new(0));
|
||||||
|
let make_producer = |calls: std::sync::Arc<AtomicUsize>, value: u64| {
|
||||||
|
move || {
|
||||||
|
let calls = calls.clone();
|
||||||
|
async move {
|
||||||
|
calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let a = cached_or_compute("key-a".to_string(), make_producer(calls.clone(), 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let b = cached_or_compute("key-b".to_string(), make_producer(calls.clone(), 2))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(a, 1);
|
||||||
|
assert_eq!(b, 2);
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cached_or_compute_errors_are_not_cached() {
|
||||||
|
let calls = std::sync::Arc::new(AtomicUsize::new(0));
|
||||||
|
let key = "test-key-err".to_string();
|
||||||
|
|
||||||
|
let calls_1 = calls.clone();
|
||||||
|
let first = cached_or_compute(key.clone(), || async move {
|
||||||
|
calls_1.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Err::<u64, String>("boom".to_string())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(first.is_err());
|
||||||
|
|
||||||
|
let calls_2 = calls.clone();
|
||||||
|
let second = cached_or_compute(key.clone(), || async move {
|
||||||
|
calls_2.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(7)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(second, 7);
|
||||||
|
assert_eq!(
|
||||||
|
calls.load(Ordering::SeqCst),
|
||||||
|
2,
|
||||||
|
"an error result must not populate the cache, so the next call recomputes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── container_name ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_follows_project_rebuild_convention() {
|
||||||
|
assert_eq!(container_name("myproj"), "huskies-myproj");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
//! Fleet resource types and pure logic for the `fleet_resources` MCP tool
|
||||||
|
//! (story 1207): host disk/load/cpu/mem, per-container CPU%/mem, per-project
|
||||||
|
//! `target/`+`worktrees/` sizes, and threshold-based flagging.
|
||||||
|
//!
|
||||||
|
//! Follows the `service/gateway` conventions: this file holds pure types and
|
||||||
|
//! classification logic (no I/O); `io.rs` performs all side effects (statvfs,
|
||||||
|
//! getloadavg, `/proc/meminfo`, and `docker` subprocess calls).
|
||||||
|
|
||||||
|
/// Side effects for fleet resource collection: host stat reads, `docker
|
||||||
|
/// stats`/`docker exec` subprocess calls, and a TTL-cached `du` helper.
|
||||||
|
pub mod io;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Host machine (gateway node) resource snapshot.
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
|
pub struct HostStats {
|
||||||
|
/// Free bytes on the filesystem containing the gateway's workspace root.
|
||||||
|
pub disk_free_bytes: u64,
|
||||||
|
/// Total bytes on the filesystem containing the gateway's workspace root.
|
||||||
|
pub disk_total_bytes: u64,
|
||||||
|
/// 1-minute load average.
|
||||||
|
pub load_avg_1: f64,
|
||||||
|
/// 5-minute load average.
|
||||||
|
pub load_avg_5: f64,
|
||||||
|
/// 15-minute load average.
|
||||||
|
pub load_avg_15: f64,
|
||||||
|
/// Number of logical CPU cores available to the gateway process.
|
||||||
|
pub cpu_cores: usize,
|
||||||
|
/// Total physical memory in bytes.
|
||||||
|
pub mem_total_bytes: u64,
|
||||||
|
/// Available (not merely free) memory in bytes, per `/proc/meminfo`'s
|
||||||
|
/// `MemAvailable` estimate.
|
||||||
|
pub mem_available_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-container CPU/mem snapshot, sourced via `docker stats`.
|
||||||
|
///
|
||||||
|
/// `error` is set (with all other fields `None`) when the container is
|
||||||
|
/// unreachable or `docker stats` fails — one dead container must not fail the
|
||||||
|
/// whole `fleet_resources` call.
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
|
pub struct ContainerStats {
|
||||||
|
/// Registered project name (key in `projects.toml`).
|
||||||
|
pub project: String,
|
||||||
|
/// Docker container name (`huskies-{project}`).
|
||||||
|
pub container: String,
|
||||||
|
/// CPU usage percentage, as reported by `docker stats`.
|
||||||
|
pub cpu_percent: Option<f64>,
|
||||||
|
/// Memory currently in use, in bytes.
|
||||||
|
pub mem_usage_bytes: Option<u64>,
|
||||||
|
/// Memory limit (cgroup limit or host total), in bytes.
|
||||||
|
pub mem_limit_bytes: Option<u64>,
|
||||||
|
/// Set when `docker stats` failed for this container.
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-project on-disk footprint: `target/` build output and
|
||||||
|
/// `.huskies/worktrees/` (coder agent worktrees), measured inside the
|
||||||
|
/// project's own container via `docker exec ... find` (see `io.rs`).
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
|
pub struct ProjectDirSizes {
|
||||||
|
/// Registered project name.
|
||||||
|
pub project: String,
|
||||||
|
/// Bytes used by `/workspace/target`, or `None` if it could not be measured.
|
||||||
|
pub target_bytes: Option<u64>,
|
||||||
|
/// Bytes used by `/workspace/.huskies/worktrees`, or `None` if it could
|
||||||
|
/// not be measured.
|
||||||
|
pub worktrees_bytes: Option<u64>,
|
||||||
|
/// Set when the `docker exec ... find` measurement failed.
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Threshold configuration for [`flag_host`]. Supplied per tool call (see
|
||||||
|
/// `handle_fleet_resources_tool`) rather than read from `projects.toml`, so an
|
||||||
|
/// operator can widen/narrow thresholds per invocation.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ResourceThresholds {
|
||||||
|
/// Host free disk space (GB) below which a "warn" flag fires.
|
||||||
|
pub disk_warn_gb: u64,
|
||||||
|
/// Host free disk space (GB) below which a "critical" flag fires.
|
||||||
|
pub disk_critical_gb: u64,
|
||||||
|
/// 1-minute load average per core above which a "warn" flag fires.
|
||||||
|
pub load_warn_per_core: f64,
|
||||||
|
/// 1-minute load average per core above which a "critical" flag fires.
|
||||||
|
pub load_critical_per_core: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ResourceThresholds {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
disk_warn_gb: 50,
|
||||||
|
disk_critical_gb: 20,
|
||||||
|
load_warn_per_core: 1.0,
|
||||||
|
load_critical_per_core: 2.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single flagged resource condition, surfaced ahead of the raw readings so
|
||||||
|
/// problems "lead" the response (story 1207 AC3).
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
|
pub struct ResourceFlag {
|
||||||
|
/// Flag category: `"low_disk"` or `"high_load"`.
|
||||||
|
pub kind: String,
|
||||||
|
/// Severity: `"warn"` or `"critical"`.
|
||||||
|
pub level: String,
|
||||||
|
/// Human-readable detail (e.g. current reading vs. threshold).
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full `fleet_resources` response payload.
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
|
pub struct FleetResources {
|
||||||
|
/// Flagged problems, first so they lead the response.
|
||||||
|
pub flags: Vec<ResourceFlag>,
|
||||||
|
/// Host-level resource snapshot.
|
||||||
|
pub host: HostStats,
|
||||||
|
/// Per-container CPU/mem snapshots.
|
||||||
|
pub containers: Vec<ContainerStats>,
|
||||||
|
/// Per-project `target/`/`worktrees/` directory sizes.
|
||||||
|
pub projects: Vec<ProjectDirSizes>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BYTES_PER_GB: u64 = 1_000_000_000;
|
||||||
|
|
||||||
|
/// Flag host disk/load conditions against `thresholds`. Pure — no I/O.
|
||||||
|
///
|
||||||
|
/// Mirrors `disk_watch::classify_level`'s "below X" boundary semantics for
|
||||||
|
/// disk (free space strictly below the threshold triggers the flag) and uses
|
||||||
|
/// "at or above" for load (a load average right at the threshold already
|
||||||
|
/// indicates saturation, unlike free-space headroom).
|
||||||
|
pub fn flag_host(host: &HostStats, thresholds: &ResourceThresholds) -> Vec<ResourceFlag> {
|
||||||
|
let mut flags = Vec::new();
|
||||||
|
|
||||||
|
if host.disk_free_bytes < thresholds.disk_critical_gb * BYTES_PER_GB {
|
||||||
|
flags.push(ResourceFlag {
|
||||||
|
kind: "low_disk".to_string(),
|
||||||
|
level: "critical".to_string(),
|
||||||
|
detail: format!(
|
||||||
|
"{:.1} GB free (critical threshold: {} GB)",
|
||||||
|
host.disk_free_bytes as f64 / BYTES_PER_GB as f64,
|
||||||
|
thresholds.disk_critical_gb
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if host.disk_free_bytes < thresholds.disk_warn_gb * BYTES_PER_GB {
|
||||||
|
flags.push(ResourceFlag {
|
||||||
|
kind: "low_disk".to_string(),
|
||||||
|
level: "warn".to_string(),
|
||||||
|
detail: format!(
|
||||||
|
"{:.1} GB free (warn threshold: {} GB)",
|
||||||
|
host.disk_free_bytes as f64 / BYTES_PER_GB as f64,
|
||||||
|
thresholds.disk_warn_gb
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if host.cpu_cores > 0 {
|
||||||
|
let per_core = host.load_avg_1 / host.cpu_cores as f64;
|
||||||
|
if per_core >= thresholds.load_critical_per_core {
|
||||||
|
flags.push(ResourceFlag {
|
||||||
|
kind: "high_load".to_string(),
|
||||||
|
level: "critical".to_string(),
|
||||||
|
detail: format!(
|
||||||
|
"load1={:.2} across {} core(s) ({:.2}/core, critical threshold: {:.2}/core)",
|
||||||
|
host.load_avg_1, host.cpu_cores, per_core, thresholds.load_critical_per_core
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if per_core >= thresholds.load_warn_per_core {
|
||||||
|
flags.push(ResourceFlag {
|
||||||
|
kind: "high_load".to_string(),
|
||||||
|
level: "warn".to_string(),
|
||||||
|
detail: format!(
|
||||||
|
"load1={:.2} across {} core(s) ({:.2}/core, warn threshold: {:.2}/core)",
|
||||||
|
host.load_avg_1, host.cpu_cores, per_core, thresholds.load_warn_per_core
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flags
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `docker stats` CPU percentage field like `"12.34%"`.
|
||||||
|
pub fn parse_docker_cpu_percent(s: &str) -> Option<f64> {
|
||||||
|
s.trim().trim_end_matches('%').parse::<f64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single `docker stats`-formatted size like `"12.3MiB"`, `"1.5GiB"`,
|
||||||
|
/// or `"512B"` into bytes. Handles the IEC-binary units `docker stats` uses.
|
||||||
|
pub fn parse_docker_size(s: &str) -> Option<u64> {
|
||||||
|
let s = s.trim();
|
||||||
|
let unit_start = s.find(|c: char| !c.is_ascii_digit() && c != '.')?;
|
||||||
|
let (number, unit) = s.split_at(unit_start);
|
||||||
|
let value: f64 = number.parse().ok()?;
|
||||||
|
let multiplier: f64 = match unit {
|
||||||
|
"B" => 1.0,
|
||||||
|
"KiB" | "KB" => 1024.0,
|
||||||
|
"MiB" | "MB" => 1024.0 * 1024.0,
|
||||||
|
"GiB" | "GB" => 1024.0 * 1024.0 * 1024.0,
|
||||||
|
"TiB" | "TB" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((value * multiplier) as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `docker stats` `MemUsage` field like `"58.14MiB / 3.842GiB"` into
|
||||||
|
/// `(used_bytes, limit_bytes)`.
|
||||||
|
pub fn parse_docker_mem_usage(s: &str) -> Option<(u64, u64)> {
|
||||||
|
let (used, limit) = s.split_once(" / ")?;
|
||||||
|
Some((parse_docker_size(used)?, parse_docker_size(limit)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `MemTotal`/`MemAvailable` (in kB) out of `/proc/meminfo` contents
|
||||||
|
/// into `(total_bytes, available_bytes)`.
|
||||||
|
pub fn parse_meminfo(contents: &str) -> Option<(u64, u64)> {
|
||||||
|
let mut total = None;
|
||||||
|
let mut available = None;
|
||||||
|
for line in contents.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("MemTotal:") {
|
||||||
|
total = parse_meminfo_kb_field(rest);
|
||||||
|
} else if let Some(rest) = line.strip_prefix("MemAvailable:") {
|
||||||
|
available = parse_meminfo_kb_field(rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((total?, available?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `/proc/meminfo` value field like `" 16384000 kB"` into bytes.
|
||||||
|
fn parse_meminfo_kb_field(s: &str) -> Option<u64> {
|
||||||
|
let kb: u64 = s.trim().trim_end_matches("kB").trim().parse().ok()?;
|
||||||
|
Some(kb * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn thresholds() -> ResourceThresholds {
|
||||||
|
ResourceThresholds {
|
||||||
|
disk_warn_gb: 50,
|
||||||
|
disk_critical_gb: 20,
|
||||||
|
load_warn_per_core: 1.0,
|
||||||
|
load_critical_per_core: 2.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host(disk_free_gb: u64, load_avg_1: f64, cpu_cores: usize) -> HostStats {
|
||||||
|
HostStats {
|
||||||
|
disk_free_bytes: disk_free_gb * BYTES_PER_GB,
|
||||||
|
disk_total_bytes: 500 * BYTES_PER_GB,
|
||||||
|
load_avg_1,
|
||||||
|
load_avg_5: load_avg_1,
|
||||||
|
load_avg_15: load_avg_1,
|
||||||
|
cpu_cores,
|
||||||
|
mem_total_bytes: 16 * BYTES_PER_GB,
|
||||||
|
mem_available_bytes: 8 * BYTES_PER_GB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── flag_host: disk ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_host_healthy_disk_and_load_produces_no_flags() {
|
||||||
|
let flags = flag_host(&host(100, 0.5, 4), &thresholds());
|
||||||
|
assert!(flags.is_empty(), "expected no flags, got: {flags:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_host_below_warn_gb_flags_warn() {
|
||||||
|
let flags = flag_host(&host(49, 0.5, 4), &thresholds());
|
||||||
|
assert_eq!(flags.len(), 1);
|
||||||
|
assert_eq!(flags[0].kind, "low_disk");
|
||||||
|
assert_eq!(flags[0].level, "warn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_host_at_warn_boundary_is_ok() {
|
||||||
|
// Exactly at warn_gb is not below it, so no flag (matches disk_watch semantics).
|
||||||
|
let flags = flag_host(&host(50, 0.5, 4), &thresholds());
|
||||||
|
assert!(flags.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_host_below_critical_gb_flags_critical_not_warn() {
|
||||||
|
let flags = flag_host(&host(19, 0.5, 4), &thresholds());
|
||||||
|
assert_eq!(flags.len(), 1);
|
||||||
|
assert_eq!(flags[0].kind, "low_disk");
|
||||||
|
assert_eq!(flags[0].level, "critical");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── flag_host: load ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_host_load_at_warn_per_core_flags_warn() {
|
||||||
|
// 4 cores, warn threshold 1.0/core => load1 == 4.0 triggers warn.
|
||||||
|
let flags = flag_host(&host(100, 4.0, 4), &thresholds());
|
||||||
|
assert_eq!(flags.len(), 1);
|
||||||
|
assert_eq!(flags[0].kind, "high_load");
|
||||||
|
assert_eq!(flags[0].level, "warn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_host_load_at_critical_per_core_flags_critical_not_warn() {
|
||||||
|
// 4 cores, critical threshold 2.0/core => load1 == 8.0 triggers critical only.
|
||||||
|
let flags = flag_host(&host(100, 8.0, 4), &thresholds());
|
||||||
|
assert_eq!(flags.len(), 1);
|
||||||
|
assert_eq!(flags[0].kind, "high_load");
|
||||||
|
assert_eq!(flags[0].level, "critical");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_host_zero_cores_does_not_panic_or_flag_load() {
|
||||||
|
let flags = flag_host(&host(100, 10.0, 0), &thresholds());
|
||||||
|
assert!(flags.iter().all(|f| f.kind != "high_load"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_host_disk_and_load_both_flag_together() {
|
||||||
|
let flags = flag_host(&host(10, 10.0, 4), &thresholds());
|
||||||
|
assert_eq!(flags.len(), 2);
|
||||||
|
assert!(flags.iter().any(|f| f.kind == "low_disk"));
|
||||||
|
assert!(flags.iter().any(|f| f.kind == "high_load"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parse_docker_cpu_percent ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cpu_percent_typical() {
|
||||||
|
assert_eq!(parse_docker_cpu_percent("12.34%"), Some(12.34));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cpu_percent_zero() {
|
||||||
|
assert_eq!(parse_docker_cpu_percent("0.00%"), Some(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cpu_percent_invalid_is_none() {
|
||||||
|
assert_eq!(parse_docker_cpu_percent("n/a"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parse_docker_size / parse_docker_mem_usage ────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_size_bytes_no_unit_suffix() {
|
||||||
|
assert_eq!(parse_docker_size("512B"), Some(512));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_size_mib() {
|
||||||
|
assert_eq!(parse_docker_size("1MiB"), Some(1024 * 1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_size_fractional_gib() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_docker_size("1.5GiB"),
|
||||||
|
Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_size_unknown_unit_is_none() {
|
||||||
|
assert_eq!(parse_docker_size("3.2XB"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mem_usage_typical() {
|
||||||
|
let (used, limit) = parse_docker_mem_usage("58.14MiB / 3.842GiB").unwrap();
|
||||||
|
assert_eq!(used, (58.14 * 1024.0 * 1024.0) as u64);
|
||||||
|
assert_eq!(limit, (3.842 * 1024.0 * 1024.0 * 1024.0) as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mem_usage_missing_separator_is_none() {
|
||||||
|
assert_eq!(parse_docker_mem_usage("58.14MiB"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parse_meminfo ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_meminfo_extracts_total_and_available() {
|
||||||
|
let sample = "MemTotal: 16384000 kB\nMemFree: 1000000 kB\nMemAvailable: 8000000 kB\n";
|
||||||
|
let (total, available) = parse_meminfo(sample).unwrap();
|
||||||
|
assert_eq!(total, 16_384_000 * 1024);
|
||||||
|
assert_eq!(available, 8_000_000 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_meminfo_missing_fields_is_none() {
|
||||||
|
assert_eq!(parse_meminfo("MemFree: 1000 kB\n"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FleetResources shape ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fleet_resources_serializes_expected_top_level_keys() {
|
||||||
|
let resources = FleetResources {
|
||||||
|
flags: vec![ResourceFlag {
|
||||||
|
kind: "low_disk".to_string(),
|
||||||
|
level: "warn".to_string(),
|
||||||
|
detail: "40.0 GB free".to_string(),
|
||||||
|
}],
|
||||||
|
host: host(40, 0.5, 4),
|
||||||
|
containers: vec![ContainerStats {
|
||||||
|
project: "huskies".to_string(),
|
||||||
|
container: "huskies-huskies".to_string(),
|
||||||
|
cpu_percent: Some(12.3),
|
||||||
|
mem_usage_bytes: Some(100),
|
||||||
|
mem_limit_bytes: Some(1000),
|
||||||
|
error: None,
|
||||||
|
}],
|
||||||
|
projects: vec![ProjectDirSizes {
|
||||||
|
project: "huskies".to_string(),
|
||||||
|
target_bytes: Some(123),
|
||||||
|
worktrees_bytes: Some(456),
|
||||||
|
error: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(&resources).unwrap();
|
||||||
|
assert!(value.get("flags").unwrap().is_array());
|
||||||
|
assert!(value.get("host").unwrap().get("disk_free_bytes").is_some());
|
||||||
|
assert!(value.get("containers").unwrap().is_array());
|
||||||
|
assert!(value.get("projects").unwrap().is_array());
|
||||||
|
assert_eq!(value["containers"][0]["cpu_percent"], 12.3);
|
||||||
|
assert_eq!(value["projects"][0]["target_bytes"], 123);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user